Merge branch 'master' into storage_space

This commit is contained in:
Tanner Collin 2023-05-29 12:16:51 -06:00
commit 972b9492d8
36 changed files with 1407 additions and 210 deletions

View File

@ -12,9 +12,10 @@ for model in app_models:
pass pass
try: try:
if hasattr(model, 'MY_FIELDS'): if hasattr(model, 'list_display'):
MyAdmin.list_display = model.MY_FIELDS MyAdmin.list_display = model.list_display
MyAdmin.search_fields = model.MY_FIELDS if hasattr(model, 'search_fields'):
MyAdmin.search_fields = model.search_fields
admin.site.register(model, MyAdmin) admin.site.register(model, MyAdmin)
except AlreadyRegistered: except AlreadyRegistered:

View File

@ -0,0 +1,26 @@
<!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>Your Protospace member dues are behind by two months and you are now "overdue".<br></div>
<div><br></div>
<div>You are paid up until [date]. Please pay your dues to prevent having your card and account deactivated by the system.<br></div>
<div><br></div>
<div>You can log into the portal and pay here:<br></div>
<div><a href="https://my.protospace.ca/paymaster">https://my.protospace.ca/paymaster</a><br></div>
<div><br></div>
<div>Or send e-Transfer to info@protospace.ca or hand a director cash.<br></div>
<div><br></div>
<div>If there has been an error or you want to reply to this email, please click "reply-all" since the Spaceport inbox does not exist.<br></div>
<div><br></div>
<div>You won't recieve any other emails about this.<br></div>
<div><br></div>
<div>Thanks,</div>
<div>Spaceport<br></div>
</body>
</html>

View File

@ -0,0 +1,19 @@
Hi [name],
Your Protospace member dues are behind by two months and you are now "overdue".
You are paid up until [date]. Please pay your dues to prevent having your
account and card deactivated by the system.
You can log into the portal and pay here:
https://my.protospace.ca/paymaster
Or send e-Transfer to info@protospace.ca or hand a director cash.
If there has been an error or you want to reply to this email, please click
"reply-all" since the Spaceport inbox does not exist.
You won't recieve any other emails about this.
Thanks,
Spaceport

View File

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils.timezone import now from django.utils.timezone import now
from apiserver.api import models, utils, utils_stats from apiserver.api import models, utils, utils_stats, utils_email
from datetime import datetime, timedelta
import time import time
@ -33,6 +34,132 @@ class Command(BaseCommand):
utils.gen_search_strings() utils.gen_search_strings()
def send_class_reminders(self):
# sends reminders to instructors that they are teaching a class
# within 6-7 hours from now
count = 0
now = utils.now_alberta_tz()
current_hour_start = now.replace(minute=0, second=0, microsecond=0)
in_six_hours = current_hour_start + timedelta(hours=6)
in_seven_hours = current_hour_start + timedelta(hours=7)
sessions = models.Session.objects.all()
reminder_sessions = sessions.filter(
datetime__gte=in_six_hours,
datetime__lt=in_seven_hours,
)
if reminder_sessions.count() == 0:
self.stdout.write('No classes found within timeframe, returning')
return 0
self.stdout.write('Found {} reminder sessions between {} and {} mountain time.'.format(
reminder_sessions.count(),
str(in_six_hours),
str(in_seven_hours),
))
for session in reminder_sessions:
self.stdout.write('Session {} instructor {}:'.format(
str(session),
session.instructor.username,
))
if session.is_cancelled:
self.stdout.write(' Is cancelled, skipping.')
continue
if session.course.id in [317, 273, 413]:
self.stdout.write(' Is members meeting or cleanup, skipping.')
continue
if session.course.tags in ['Event', 'Outing']:
self.stdout.write(' Is only outing or event, skipping.')
continue
self.stdout.write(' Emailing {} {}:'.format(session.instructor.username, session.instructor.email))
utils.alert_tanner('Class reminder {} for {} {}'.format(
str(session),
session.instructor.username,
session.instructor.email,
))
self.stdout.write(' Sent class reminder email.')
count += 1
return count
def send_attendance_reminders(self):
# sends reminders to instructors to mark attendance for classes
# that happened 6-7 hours ago if they haven't already
count = 0
now = utils.now_alberta_tz()
current_hour_start = now.replace(minute=0, second=0, microsecond=0)
six_hours_ago = current_hour_start - timedelta(hours=6)
seven_hours_ago = current_hour_start - timedelta(hours=7)
sessions = models.Session.objects.all()
reminder_sessions = sessions.filter(
datetime__gte=seven_hours_ago,
datetime__lt=six_hours_ago,
)
if reminder_sessions.count() == 0:
self.stdout.write('No classes found within timeframe, returning')
return 0
self.stdout.write('Found {} sessions between {} and {} mountain time.'.format(
reminder_sessions.count(),
str(seven_hours_ago),
str(six_hours_ago),
))
for session in reminder_sessions:
self.stdout.write('Session {} instructor {}:'.format(
str(session),
session.instructor.username,
))
if session.is_cancelled:
self.stdout.write(' Is cancelled, skipping.')
continue
if session.course.id in [317, 273, 413]:
self.stdout.write(' Is members meeting or cleanup, skipping.')
continue
if session.course.tags in ['Event', 'Outing']:
self.stdout.write(' Is only outing or event, skipping.')
continue
if session.students.count() == 0:
self.stdout.write(' Class is empty, skipping.')
continue
if session.students.filter(attendance_status='Attended').count() > 0:
self.stdout.write(' Instructor already marked attendance, skipping.')
continue
self.stdout.write(' Emailing {} {}:'.format(session.instructor.username, session.instructor.email))
utils.alert_tanner('Attendance reminder {} for {} {}'.format(
str(session),
session.instructor.username,
session.instructor.email,
))
self.stdout.write(' Sent attendance reminder email.')
count += 1
return count
def handle(self, *args, **options): def handle(self, *args, **options):
self.stdout.write('{} - Beginning hourly tasks'.format(str(now()))) self.stdout.write('{} - Beginning hourly tasks'.format(str(now())))
@ -41,6 +168,12 @@ class Command(BaseCommand):
self.generate_stats() self.generate_stats()
self.stdout.write('Generated stats') self.stdout.write('Generated stats')
count = self.send_class_reminders()
self.stdout.write('Sent {} class reminders'.format(count))
count = self.send_attendance_reminders()
self.stdout.write('Sent {} attendance reminders'.format(count))
self.stdout.write('Completed tasks in {} s'.format( self.stdout.write('Completed tasks in {} s'.format(
str(time.time() - start)[:4] str(time.time() - start)[:4]
)) ))

View File

@ -7,12 +7,14 @@ from django.utils.timezone import now, pytz
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from simple_history import register from simple_history import register
TIMEZONE_CALGARY = pytz.timezone('America/Edmonton')
register(User) register(User)
IGNORE = '+' IGNORE = '+'
def today_alberta_tz(): def today_alberta_tz():
return datetime.now(pytz.timezone('America/Edmonton')).date() return datetime.now(TIMEZONE_CALGARY).date()
class Member(models.Model): class Member(models.Model):
user = models.OneToOneField(User, related_name='member', blank=True, null=True, on_delete=models.SET_NULL) user = models.OneToOneField(User, related_name='member', blank=True, null=True, on_delete=models.SET_NULL)
@ -61,9 +63,10 @@ class Member(models.Model):
history = HistoricalRecords(excluded_fields=['member_forms']) history = HistoricalRecords(excluded_fields=['member_forms'])
MY_FIELDS = ['user', 'preferred_name', 'last_name', 'status'] list_display = ['user', 'preferred_name', 'last_name', 'status']
search_fields = ['user__username', 'preferred_name', 'last_name', 'status']
def __str__(self): def __str__(self):
return self.user.username return getattr(self.user, 'username', 'None')
class Transaction(models.Model): class Transaction(models.Model):
user = models.ForeignKey(User, related_name='transactions', blank=True, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(User, related_name='transactions', blank=True, null=True, on_delete=models.SET_NULL)
@ -72,7 +75,7 @@ class Transaction(models.Model):
member_id = models.IntegerField(blank=True, null=True) member_id = models.IntegerField(blank=True, null=True)
date = models.DateField(default=today_alberta_tz) date = models.DateField(default=today_alberta_tz)
amount = models.DecimalField(max_digits=7, decimal_places=2) amount = models.DecimalField(max_digits=7, decimal_places=2)
reference_number = models.CharField(max_length=32, blank=True, null=True) reference_number = models.CharField(max_length=64, blank=True, null=True)
memo = models.TextField(blank=True, null=True) memo = models.TextField(blank=True, null=True)
number_of_membership_months = models.IntegerField(blank=True, null=True) number_of_membership_months = models.IntegerField(blank=True, null=True)
payment_method = models.TextField(blank=True, null=True) payment_method = models.TextField(blank=True, null=True)
@ -89,7 +92,8 @@ class Transaction(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['date', 'user', 'amount', 'protocoin', 'account_type', 'category'] list_display = ['date', 'user', 'amount', 'protocoin', 'account_type', 'category']
search_fields = ['date', 'user__username', 'account_type', 'category']
def __str__(self): def __str__(self):
return '%s tx %s' % (user.username, date) return '%s tx %s' % (user.username, date)
@ -101,7 +105,8 @@ class PayPalHint(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['account', 'user'] list_display = ['account', 'user']
search_fields = ['account', 'user__username']
def __str__(self): def __str__(self):
return self.account return self.account
@ -112,7 +117,8 @@ class IPN(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['datetime', 'status'] list_display = ['datetime', 'status']
search_fields = ['datetime', 'status']
def __str__(self): def __str__(self):
return self.datetime return self.datetime
@ -128,7 +134,8 @@ class Card(models.Model):
history = HistoricalRecords(excluded_fields=['last_seen_at', 'last_seen']) history = HistoricalRecords(excluded_fields=['last_seen_at', 'last_seen'])
MY_FIELDS = ['card_number', 'user', 'last_seen'] list_display = ['card_number', 'user', 'last_seen']
search_fields = ['card_number', 'user__username', 'last_seen']
def __str__(self): def __str__(self):
return self.card_number return self.card_number
@ -140,7 +147,8 @@ class Course(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['name', 'id'] list_display = ['name', 'id']
search_fields = ['name', 'id']
def __str__(self): def __str__(self):
return self.name return self.name
@ -156,9 +164,10 @@ class Session(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['datetime', 'course', 'instructor'] list_display = ['datetime', 'course', 'instructor']
search_fields = ['datetime', 'course__name', 'instructor__username']
def __str__(self): def __str__(self):
return '%s @ %s' % (self.course.name, self.datetime) return '%s @ %s' % (self.course.name, self.datetime.astimezone(TIMEZONE_CALGARY).strftime('%Y-%m-%d %-I:%M %p'))
class Training(models.Model): class Training(models.Model):
user = models.ForeignKey(User, related_name='training', blank=True, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(User, related_name='training', blank=True, null=True, on_delete=models.SET_NULL)
@ -171,7 +180,8 @@ class Training(models.Model):
history = HistoricalRecords() history = HistoricalRecords()
MY_FIELDS = ['session', 'user'] list_display = ['session', 'user']
search_fields = ['session__course__name', 'user__username']
def __str__(self): def __str__(self):
return '%s taking %s @ %s' % (self.user, self.session.course.name, self.session.datetime) return '%s taking %s @ %s' % (self.user, self.session.course.name, self.session.datetime)
@ -181,7 +191,8 @@ class Interest(models.Model):
satisfied_by = models.ForeignKey(Session, related_name='satisfies', null=True, on_delete=models.SET_NULL) satisfied_by = models.ForeignKey(Session, related_name='satisfies', null=True, on_delete=models.SET_NULL)
MY_FIELDS = ['user', 'course', 'satisfied_by'] list_display = ['user', 'course', 'satisfied_by']
search_fields = ['user__username', 'course__name']
def __str__(self): def __str__(self):
return '%s interested in %s' % (self.user, self.course) return '%s interested in %s' % (self.user, self.course)
@ -197,7 +208,8 @@ class StatsMemberCount(models.Model):
vetted_count = models.IntegerField() vetted_count = models.IntegerField()
subscriber_count = models.IntegerField() subscriber_count = models.IntegerField()
MY_FIELDS = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count'] list_display = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count']
search_fields = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count']
class StatsSignupCount(models.Model): class StatsSignupCount(models.Model):
month = models.DateField() month = models.DateField()
@ -205,13 +217,15 @@ class StatsSignupCount(models.Model):
retain_count = models.IntegerField(default=0) retain_count = models.IntegerField(default=0)
vetted_count = models.IntegerField(default=0) vetted_count = models.IntegerField(default=0)
MY_FIELDS = ['month', 'signup_count', 'retain_count', 'vetted_count'] list_display = ['month', 'signup_count', 'retain_count', 'vetted_count']
search_fields = ['month', 'signup_count', 'retain_count', 'vetted_count']
class StatsSpaceActivity(models.Model): class StatsSpaceActivity(models.Model):
date = models.DateField(default=today_alberta_tz) date = models.DateField(default=today_alberta_tz)
card_scans = models.IntegerField() card_scans = models.IntegerField()
MY_FIELDS = ['date', 'card_scans'] list_display = ['date', 'card_scans']
search_fields = ['date', 'card_scans']
class Usage(models.Model): class Usage(models.Model):
user = models.ForeignKey(User, related_name='usages', blank=True, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(User, related_name='usages', blank=True, null=True, on_delete=models.SET_NULL)
@ -221,7 +235,7 @@ class Usage(models.Model):
device = models.CharField(max_length=64) device = models.CharField(max_length=64)
started_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True) finished_at = models.DateTimeField(null=True)
deleted_at = models.DateTimeField(null=True) deleted_at = models.DateTimeField(null=True, blank=True)
num_seconds = models.IntegerField() num_seconds = models.IntegerField()
num_reports = models.IntegerField() num_reports = models.IntegerField()
@ -230,9 +244,10 @@ class Usage(models.Model):
history = HistoricalRecords(excluded_fields=['num_reports']) history = HistoricalRecords(excluded_fields=['num_reports'])
MY_FIELDS = ['started_at', 'finished_at', 'user', 'num_seconds', 'should_bill'] list_display = ['started_at', 'finished_at', 'user', 'num_seconds', 'should_bill']
search_fields = ['started_at', 'finished_at', 'user__username']
def __str__(self): def __str__(self):
return self.started_at return str(self.started_at)
class PinballScore(models.Model): class PinballScore(models.Model):
user = models.ForeignKey(User, related_name='scores', blank=True, null=True, on_delete=models.SET_NULL) user = models.ForeignKey(User, related_name='scores', blank=True, null=True, on_delete=models.SET_NULL)
@ -246,7 +261,22 @@ class PinballScore(models.Model):
# no history # no history
MY_FIELDS = ['started_at', 'game_id', 'player', 'score', 'user'] list_display = ['started_at', 'game_id', 'player', 'score', 'user']
search_fields = ['started_at', 'game_id', 'player', 'score', 'user__username']
def __str__(self):
return str(self.started_at)
class Hosting(models.Model):
user = models.ForeignKey(User, related_name='hosting', blank=True, null=True, on_delete=models.SET_NULL)
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField()
hours = models.DecimalField(max_digits=5, decimal_places=2)
# no history
list_display = ['started_at', 'hours', 'finished_at', 'user']
search_fields = ['started_at', 'hours', 'finished_at', 'user__username']
def __str__(self): def __str__(self):
return str(self.started_at) return str(self.started_at)
@ -284,7 +314,8 @@ class HistoryIndex(models.Model):
is_system = models.BooleanField() is_system = models.BooleanField()
is_admin = models.BooleanField() is_admin = models.BooleanField()
MY_FIELDS = ['history_date', 'history_user', 'history_type', 'owner_name', 'object_name'] list_display = ['history_date', 'history_user', 'history_type', 'owner_name', 'object_name']
search_fields = ['history_date', 'history_user__username', 'history_type', 'owner_name', 'object_name']
def __str__(self): def __str__(self):
return '%s changed %s\'s %s' % (self.history_user, self.owner_name, self.object_name) return '%s changed %s\'s %s' % (self.history_user, self.owner_name, self.object_name)
@ -295,6 +326,7 @@ class HistoryChange(models.Model):
old = models.TextField() old = models.TextField()
new = models.TextField() new = models.TextField()
MY_FIELDS = ['field', 'old', 'new', 'index'] list_display = ['field', 'old', 'new', 'index']
search_fields = ['field', 'old', 'new', 'index__history_user__username']
def __str__(self): def __str__(self):
return self.field return self.field

View File

@ -158,7 +158,8 @@ class TransactionSerializer(serializers.ModelSerializer):
current_protocoin = (user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0) - instance.protocoin current_protocoin = (user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0) - instance.protocoin
new_protocoin = current_protocoin + validated_data['protocoin'] new_protocoin = current_protocoin + validated_data['protocoin']
if new_protocoin < 0: if new_protocoin < 0:
raise ValidationError(dict(category='Insufficient funds. Member only had {} protocoin.'.format(current_protocoin))) msg = 'Negative Protocoin transaction updated:\n' + str(validated_data)
utils.alert_tanner(msg)
return super().update(instance, validated_data) return super().update(instance, validated_data)
@ -633,10 +634,23 @@ class SessionSerializer(serializers.ModelSerializer):
else: else:
return None return None
def create(self, validated_data):
if validated_data['datetime'] < now() - datetime.timedelta(days=2):
msg = 'Past class creation detected:\n' + str(validated_data)
utils.alert_tanner(msg)
raise ValidationError(dict(non_field_errors='Class can\'t be in the past.'))
return super().create(validated_data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
if not self.initial_data.get('instructor_id', None): if not self.initial_data.get('instructor_id', None):
raise ValidationError(dict(instructor_id='This field is required.')) raise ValidationError(dict(instructor_id='This field is required.'))
if validated_data['datetime'] < now() - datetime.timedelta(days=2):
msg = 'Past class modification detected:\n' + str(validated_data)
utils.alert_tanner(msg)
raise ValidationError(dict(non_field_errors='Can\'t modify past class.'))
member = get_object_or_404(models.Member, id=self.initial_data['instructor_id']) member = get_object_or_404(models.Member, id=self.initial_data['instructor_id'])
if not (is_admin_director(member.user) or member.is_instructor): if not (is_admin_director(member.user) or member.is_instructor):
raise ValidationError(dict(instructor_id='Member is not an instructor.')) raise ValidationError(dict(instructor_id='Member is not an instructor.'))
@ -672,14 +686,15 @@ class CourseDetailSerializer(serializers.ModelSerializer):
continue continue
yield date yield date
def next_date(weekday, week_num=False): def next_date(weekday, week_num=False, fake_start=False):
start = fake_start or utils.today_alberta_tz()
for date in iter_matching_dates(weekday, week_num): for date in iter_matching_dates(weekday, week_num):
if date > utils.today_alberta_tz(): if date > start:
return date return date
raise raise
def course_is_usually_monthly(course): def course_is_usually_monthly(course):
two_months_ago = utils.today_alberta_tz() - datetime.timedelta(days=61) two_months_ago = utils.now_alberta_tz() - datetime.timedelta(days=61)
recent_sessions = obj.sessions.filter(datetime__gte=two_months_ago) recent_sessions = obj.sessions.filter(datetime__gte=two_months_ago)
if recent_sessions.count() < 3: if recent_sessions.count() < 3:
return True return True
@ -695,9 +710,14 @@ class CourseDetailSerializer(serializers.ModelSerializer):
dt = utils.TIMEZONE_CALGARY.localize(dt) dt = utils.TIMEZONE_CALGARY.localize(dt)
cost = 0 cost = 0
max_students = None max_students = None
elif obj.id == 317: # members' meeting 7:00 PM 3rd Thursday of odd months, Wednesday of even months elif obj.id == 317:
# members' meeting 7:00 PM 3rd Thursday of odd months, Wednesday of even months
# but December's gets skipped
next_month = next_date(calendar.WEDNESDAY, week_num=3).month next_month = next_date(calendar.WEDNESDAY, week_num=3).month
if next_month % 2 == 0: if next_month == 12:
one_month_ahead = utils.today_alberta_tz() + datetime.timedelta(days=31)
date = next_date(calendar.THURSDAY, week_num=3, fake_start=one_month_ahead)
elif next_month % 2 == 0:
date = next_date(calendar.WEDNESDAY, week_num=3) date = next_date(calendar.WEDNESDAY, week_num=3)
else: else:
date = next_date(calendar.THURSDAY, week_num=3) date = next_date(calendar.THURSDAY, week_num=3)
@ -745,6 +765,7 @@ class UserSerializer(serializers.ModelSerializer):
training = UserTrainingSerializer(many=True) training = UserTrainingSerializer(many=True)
member = MemberSerializer() member = MemberSerializer()
transactions = serializers.SerializerMethodField() transactions = serializers.SerializerMethodField()
training = serializers.SerializerMethodField()
interests = InterestSerializer(many=True) interests = InterestSerializer(many=True)
door_code = serializers.SerializerMethodField() door_code = serializers.SerializerMethodField()
wifi_pass = serializers.SerializerMethodField() wifi_pass = serializers.SerializerMethodField()
@ -771,12 +792,27 @@ class UserSerializer(serializers.ModelSerializer):
def get_transactions(self, obj): def get_transactions(self, obj):
queryset = models.Transaction.objects.filter(user=obj) queryset = models.Transaction.objects.filter(user=obj)
queryset = queryset.select_related('user', 'user__member')
queryset = queryset.exclude(category='Memberships:Fake Months') queryset = queryset.exclude(category='Memberships:Fake Months')
queryset = queryset.order_by('-id', '-date') queryset = queryset.order_by('-id', '-date')
serializer = TransactionSerializer(data=queryset, many=True) serializer = TransactionSerializer(data=queryset, many=True)
serializer.is_valid() serializer.is_valid()
return serializer.data return serializer.data
def get_training(self, obj):
queryset = obj.training
queryset = queryset.select_related(
'session',
'session__course',
'session__instructor',
'session__instructor__member'
)
queryset = queryset.prefetch_related('session__students')
queryset = queryset.order_by('-id')
serializer = UserTrainingSerializer(data=queryset, many=True)
serializer.is_valid()
return serializer.data
def get_door_code(self, obj): def get_door_code(self, obj):
if not obj.member.paused_date and obj.cards.count(): if not obj.member.paused_date and obj.cards.count():
return secrets.DOOR_CODE return secrets.DOOR_CODE

View File

@ -19,11 +19,20 @@ class LoggingThrottle(throttling.BaseThrottle):
if path.startswith('/lockout/'): if path.startswith('/lockout/'):
return True return True
elif path == '/stats/sign/': elif path == '/stats/sign/':
pass pass # log this one
elif path.startswith('/stats/'): elif path.startswith('/stats/'):
return True return True
elif path == '/sessions/' and user == None: elif path == '/sessions/' and user == None:
return True return True
elif path in [
'/pinball/high_scores/',
'/pinball/monthly_high_scores/',
'/protocoin/printer_balance/',
'/hosting/high_scores/',
'/stats/ord2/printer3d/',
'/stats/ord3/printer3d/'
]:
return True
if request.data: if request.data:
if type(request.data) is not dict: if type(request.data) is not dict:

View File

@ -70,7 +70,7 @@ def calc_member_status(expire_date, fake_date=None):
if today + timedelta(days=29) < expire_date: if today + timedelta(days=29) < expire_date:
return 'Prepaid' return 'Prepaid'
elif difference <= -3: elif difference <= -3:
return 'Former Member' return 'Expired Member'
elif today - timedelta(days=29) >= expire_date: elif today - timedelta(days=29) >= expire_date:
return 'Overdue' return 'Overdue'
elif today < expire_date: elif today < expire_date:
@ -104,11 +104,26 @@ def tally_membership_months(member, fake_date=None):
status = calc_member_status(expire_date, fake_date) status = calc_member_status(expire_date, fake_date)
if member.expire_date != expire_date or member.status != status: if member.expire_date != expire_date or member.status != status:
previous_status = member.status
member.expire_date = expire_date member.expire_date = expire_date
member.status = status member.status = status
if status == 'Former Member': if status == 'Expired Member':
member.paused_date = expire_date member.paused_date = today_alberta_tz()
msg = 'Member has expired: {} {}'.format(member.preferred_name, member.last_name)
alert_tanner(msg)
logger.info(msg)
if status == 'Overdue':
if previous_status == 'Due':
msg = 'Member has become Overdue: {} {}'.format(member.preferred_name, member.last_name)
alert_tanner(msg)
logger.info(msg)
utils_email.send_overdue_email(member)
else:
logger.info('Skipping email because member wasn\'t due before.')
member.save() member.save()
logging.debug('Tallied %s membership months: updated.', member) logging.debug('Tallied %s membership months: updated.', member)
@ -466,7 +481,10 @@ def gen_member_forms(member):
def custom_exception_handler(exc, context): def custom_exception_handler(exc, context):
response = exception_handler(exc, context) response = exception_handler(exc, context)
if response is not None: if response is not None:
logging.warning('Response: %s', json.dumps(exc.detail)) if hasattr(exc, 'detail'):
logging.warning('Response: %s', json.dumps(exc.detail))
else:
logging.warning('Response: %s', exc)
return response return response
def log_transaction(tx): def log_transaction(tx):

View File

@ -121,10 +121,34 @@ def send_usage_bill_email(user, device, month, minutes, overage, bill):
subject='{} {} Usage Bill'.format(month, device), subject='{} {} Usage Bill'.format(month, device),
message=email_text, message=email_text,
from_email=None, # defaults to DEFAULT_FROM_EMAIL from_email=None, # defaults to DEFAULT_FROM_EMAIL
recipient_list=[user.email, 'directors@protospace.ca', 'protospace@tannercollin.com'], recipient_list=[user.email, 'directors@protospace.ca', 'spaceport@tannercollin.com'],
) )
if not settings.EMAIL_HOST: if not settings.EMAIL_HOST:
time.sleep(0.5) # simulate slowly sending emails when logging to console time.sleep(0.5) # simulate slowly sending emails when logging to console
logger.info('Sent usage bill email:\n' + email_text) logger.info('Sent usage bill email:\n' + email_text)
def send_overdue_email(member):
def replace_fields(text):
return text.replace(
'[name]', member.preferred_name,
).replace(
'[date]', member.expire_date.strftime('%B %d, %Y'),
)
with open(EMAIL_DIR + 'overdue.txt', 'r') as f:
email_text = replace_fields(f.read())
with open(EMAIL_DIR + 'overdue.html', 'r') as f:
email_html = replace_fields(f.read())
send_mail(
subject='Protospace member dues overdue',
message=email_text,
from_email=None, # defaults to DEFAULT_FROM_EMAIL
recipient_list=[member.user.email, 'directors@protospace.ca', 'spaceport@tannercollin.com'],
html_message=email_html,
)
logger.info('Sent overdue email:\n' + email_text)

View File

@ -156,6 +156,9 @@ def create_member_dues_tx(data, member, num_months, deal):
elif deal == 3 and num_months == 2: elif deal == 3 and num_months == 2:
num_months = 3 num_months = 3
deal_str = '3 for 2, ' deal_str = '3 for 2, '
elif num_months == 11: # handle pre-Spaceport yearly subs
num_months = 12
deal_str = '12 for 11 (legacy), '
else: else:
deal_str = '' deal_str = ''
@ -247,7 +250,7 @@ def check_training(data, training_id, amount):
if training.attendance_status == 'Waiting for payment': if training.attendance_status == 'Waiting for payment':
training.attendance_status = 'Confirmed' training.attendance_status = 'Confirmed'
training.paid_date = datetime.date.today() training.paid_date = utils.today_alberta_tz()
training.save() training.save()
logger.info('IPN - Amount valid for training cost, id: ' + str(training.id)) logger.info('IPN - Amount valid for training cost, id: ' + str(training.id))

View File

@ -28,6 +28,9 @@ DEFAULTS = {
'sign': '', 'sign': '',
'link': '', 'link': '',
'autoscan': '', 'autoscan': '',
'last_scan': {},
'closing': {},
'printer3d': {},
} }
if secrets.MUMBLE: if secrets.MUMBLE:

View File

@ -23,8 +23,7 @@ import icalendar
import datetime, time import datetime, time
import io import io
import csv import csv
import xmltodict
import requests
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap, utils_email from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap, utils_email
from .permissions import ( from .permissions import (
@ -137,7 +136,10 @@ class SearchViewSet(Base, Retrieve):
pinball_score=Max('user__scores__score'), pinball_score=Max('user__scores__score'),
).exclude(pinball_score__isnull=True).order_by('-pinball_score') ).exclude(pinball_score__isnull=True).order_by('-pinball_score')
elif sort == 'everyone': elif sort == 'everyone':
queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count', 'id') queryset = queryset.annotate(
protocoin_sum=Sum('user__transactions__protocoin'),
tx_sum=Sum('user__transactions__amount'),
).order_by('-protocoin_sum', '-tx_sum', 'id')
elif sort == 'best_looking': elif sort == 'best_looking':
queryset = [] queryset = []
@ -187,20 +189,42 @@ class MemberViewSet(Base, Retrieve, Update):
if not is_admin_director(self.request.user): if not is_admin_director(self.request.user):
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
member = self.get_object() member = self.get_object()
member.status = 'Former Member' member.status = 'Paused Member'
member.paused_date = utils.today_alberta_tz() member.paused_date = utils.today_alberta_tz()
member.save() member.save()
msg = 'Member has been paused: {} {}'.format(member.preferred_name, member.last_name)
utils.alert_tanner(msg)
logger.info(msg)
return Response(200) return Response(200)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def unpause(self, request, pk=None): def unpause(self, request, pk=None):
if not is_admin_director(self.request.user): if not is_admin_director(self.request.user):
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
today = utils.today_alberta_tz()
member = self.get_object() member = self.get_object()
member.current_start_date = utils.today_alberta_tz()
difference = utils.today_alberta_tz() - member.paused_date
if difference.days > 370: # give some leeway
logging.info('Member has been away for %s days (since %s), unvetting...', difference.days, member.paused_date)
member.vetted_date = None
member.orientation_date = None
member.lathe_cert_date = None
member.mill_cert_date = None
member.wood_cert_date = None
member.wood2_cert_date = None
member.tormach_cnc_cert_date = None
member.precix_cnc_cert_date = None
member.rabbit_cert_date = None
member.trotec_cert_date = None
member.current_start_date = today
member.paused_date = None member.paused_date = None
if not member.monthly_fees: if not member.monthly_fees:
member.monthly_fees = 55 member.monthly_fees = 55
member.save() member.save()
utils.tally_membership_months(member) utils.tally_membership_months(member)
utils.gen_member_forms(member) utils.gen_member_forms(member)
@ -296,7 +320,7 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
course=session.course, course=session.course,
satisfied_by__isnull=True, satisfied_by__isnull=True,
user__member__paused_date__isnull=True user__member__paused_date__isnull=True
) )[:20]
for num, interest in enumerate(interests): for num, interest in enumerate(interests):
msg = 'Sending email {} / {}...'.format(num+1, len(interests)) msg = 'Sending email {} / {}...'.format(num+1, len(interests))
@ -309,7 +333,9 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
logger.exception(msg) logger.exception(msg)
utils.alert_tanner(msg) utils.alert_tanner(msg)
num_satisfied = interests.update(satisfied_by=session) interest_ids = interests.values('id')
num_satisfied = models.Interest.objects.filter(id__in=interest_ids).update(satisfied_by=session)
logging.info('Satisfied %s interests.', num_satisfied) logging.info('Satisfied %s interests.', num_satisfied)
def generate_ical(self, session): def generate_ical(self, session):
@ -433,6 +459,12 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
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
course_id = session.course.id
if course_id not in [317, 273, 413] and user == session.instructor:
msg = 'Self-register trickery detected:\n' + str(data.dict())
utils.alert_tanner(msg)
raise exceptions.ValidationError(dict(non_field_errors='Can\'t register the instructor. Don\'t try to trick the portal.'))
training1 = models.Training.objects.filter(user=user, session=session) training1 = models.Training.objects.filter(user=user, session=session)
if training1.exists(): if training1.exists():
@ -471,19 +503,27 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
def get_queryset(self): def get_queryset(self):
queryset = models.Transaction.objects queryset = models.Transaction.objects
month = self.request.query_params.get('month', '') month = self.request.query_params.get('month', '')
exclude_paypal = self.request.query_params.get('exclude_paypal', '') == 'true'
exclude_snacks = self.request.query_params.get('exclude_snacks', '') == 'true'
if self.action == 'list' and month: if self.action == 'list':
try: if month:
dt = datetime.datetime.strptime(month, '%Y-%m') try:
except ValueError: dt = datetime.datetime.strptime(month, '%Y-%m')
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.')) except ValueError:
queryset = queryset.filter(date__year=dt.year) raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
queryset = queryset.filter(date__month=dt.month) queryset = queryset.filter(date__year=dt.year)
queryset = queryset.exclude(category='Memberships:Fake Months') queryset = queryset.filter(date__month=dt.month)
return queryset.order_by('-date', '-id') queryset = queryset.exclude(category='Memberships:Fake Months')
elif self.action == 'list': else:
queryset = queryset.exclude(report_type__isnull=True) queryset = queryset.exclude(report_type__isnull=True)
queryset = queryset.exclude(report_type='') queryset = queryset.exclude(report_type='')
if exclude_paypal:
queryset = queryset.exclude(account_type='PayPal')
if exclude_snacks:
queryset = queryset.exclude(category='Snacks')
return queryset.order_by('-date', '-id') return queryset.order_by('-date', '-id')
else: else:
return queryset.all() return queryset.all()
@ -516,16 +556,30 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
return super().list(request) return super().list(request)
@action(detail=True, methods=['post']) @action(detail=False, methods=['get'])
def report(self, request, pk=None): def summary(self, request):
report_memo = request.data.get('report_memo', '').strip() txs = models.Transaction.objects
if not report_memo: month = self.request.query_params.get('month', '')
raise exceptions.ValidationError(dict(report_memo='This field may not be blank.'))
transaction = self.get_object() try:
transaction.report_type = 'User Flagged' dt = datetime.datetime.strptime(month, '%Y-%m')
transaction.report_memo = report_memo except ValueError:
transaction.save() raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
return Response(200)
txs = txs.filter(date__year=dt.year)
txs = txs.filter(date__month=dt.month)
txs = txs.exclude(category='Memberships:Fake Months')
result = []
for category in ['Membership', 'Snacks', 'OnAcct', 'Donation', 'Consumables', 'Purchases']:
result.append(dict(
category = category,
dollar = txs.filter(category=category).aggregate(Sum('amount'))['amount__sum'] or 0,
protocoin = -1 * (txs.filter(category=category).aggregate(Sum('protocoin'))['protocoin__sum'] or 0),
))
return Response(result)
class UserView(views.APIView): class UserView(views.APIView):
@ -555,6 +609,7 @@ class DoorViewSet(viewsets.ViewSet, List):
for card in cards: for card in cards:
member = card.user.member member = card.user.member
if member.paused_date: continue if member.paused_date: continue
if not member.vetted_date: continue
if not member.is_allowed_entry: continue if not member.is_allowed_entry: continue
active_member_cards[card.card_number] = '{} ({})'.format( active_member_cards[card.card_number] = '{} ({})'.format(
@ -574,6 +629,13 @@ class DoorViewSet(viewsets.ViewSet, List):
t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p') t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p')
logger.info('Scan - Time: {} | Name: {} {} ({})'.format(t, member.preferred_name, member.last_name, member.id)) logger.info('Scan - Time: {} | Name: {} {} ({})'.format(t, member.preferred_name, member.last_name, member.id))
last_scan = dict(
time=time.time(),
member_id=member.id,
first_name=member.preferred_name,
)
cache.set('last_scan', last_scan)
utils_stats.calc_card_scans() utils_stats.calc_card_scans()
return Response(200) return Response(200)
@ -756,7 +818,8 @@ class StatsViewSet(viewsets.ViewSet, List):
if should_count: if should_count:
start_new_use = not last_use or last_use.finished_at or last_use.username != username start_new_use = not last_use or last_use.finished_at or last_use.username != username
if start_new_use: if start_new_use:
if username_isfrom_track and time.time() - track[device]['time'] > 20*60: username_isexpired = time.time() - track[device]['time'] > 2*60*60 # two hours
if username_isfrom_track and username_isexpired:
msg = 'Usage tracker problem expired username {} for device: {}'.format(username, device) msg = 'Usage tracker problem expired username {} for device: {}'.format(username, device)
utils.alert_tanner(msg) utils.alert_tanner(msg)
logger.error(msg) logger.error(msg)
@ -891,6 +954,23 @@ class StatsViewSet(viewsets.ViewSet, List):
return Response(200) return Response(200)
@action(detail=True, methods=['post'])
def printer3d(self, request, pk=None):
printer3d = cache.get('printer3d', {})
devicename = pk
status = request.data['result']['status']
printer3d[devicename] = dict(
progress=int(status['display_status']['progress'] * 100),
#filename=status['print_stats']['filename'],
state=status['idle_timeout']['state'],
)
cache.set('printer3d', printer3d)
return Response(200)
class MemberCountViewSet(Base, List): class MemberCountViewSet(Base, List):
pagination_class = None pagination_class = None
@ -1071,6 +1151,111 @@ class InterestViewSet(Base, Retrieve, Create):
class ProtocoinViewSet(Base): class ProtocoinViewSet(Base):
@action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated])
def spend_request(self, request):
try:
with transaction.atomic():
source_user = self.request.user
source_member = source_user.member
training = None
try:
balance = float(request.data['balance'])
except KeyError:
raise exceptions.ValidationError(dict(balance='This field is required.'))
except ValueError:
raise exceptions.ValidationError(dict(balance='Invalid number.'))
try:
amount = float(request.data['amount'])
except KeyError:
raise exceptions.ValidationError(dict(amount='This field is required.'))
except ValueError:
raise exceptions.ValidationError(dict(amount='Invalid number.'))
try:
category = str(request.data['category'])
except KeyError:
raise exceptions.ValidationError(dict(category='This field is required.'))
if category not in ['Consumables', 'Donation', 'OnAcct']:
raise exceptions.ValidationError(dict(category='Invalid category.'))
if category == 'OnAcct':
try:
training_id = int(request.data['training'])
except KeyError:
raise exceptions.ValidationError(dict(training='This field is required.'))
except ValueError:
raise exceptions.ValidationError(dict(training='Invalid number.'))
training = get_object_or_404(models.Training, id=training_id)
if not training.session:
raise exceptions.ValidationError(dict(training='Invalid session.'))
if training.session.is_cancelled:
raise exceptions.ValidationError(dict(training='Class is cancelled.'))
if training.paid_date:
raise exceptions.ValidationError(dict(training='Already paid.'))
if training.session.cost != amount:
msg = 'Protocoin training payment amount mismatch:\n' + str(request.data.dict())
utils.alert_tanner(msg)
raise exceptions.ValidationError(dict(training='Class cost doesn\'t match amount.'))
memo = str(request.data.get('memo', ''))
# also prevents negative spending
if amount < 0.25:
raise exceptions.ValidationError(dict(amount='Amount too small.'))
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
source_user_balance = float(source_user_balance)
if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
if source_user_balance < amount:
raise exceptions.ValidationError(dict(amount='Insufficient funds.'))
if training:
tx_memo = 'Protocoin - Transaction spent ₱ {} on {}, session: {}, training: {}'.format(
amount,
training.session.course.name,
str(training.session.id),
str(training.id),
)
else:
tx_memo = 'Protocoin - Transaction spent ₱ {} on {}{}'.format(
amount,
category,
', memo: ' + memo if memo else ''
)
tx = models.Transaction.objects.create(
user=source_user,
protocoin=-amount,
amount=0,
number_of_membership_months=0,
account_type='Protocoin',
category=category,
info_source='System',
memo=tx_memo,
)
utils.log_transaction(tx)
if training:
if training.attendance_status == 'Waiting for payment':
training.attendance_status = 'Confirmed'
training.paid_date = utils.today_alberta_tz()
training.save()
return Response(200)
except OperationalError:
self.spend_request(request)
@action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated]) @action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated])
def send_to_member(self, request): def send_to_member(self, request):
try: try:
@ -1099,6 +1284,7 @@ class ProtocoinViewSet(Base):
except ValueError: except ValueError:
raise exceptions.ValidationError(dict(amount='Invalid number.')) raise exceptions.ValidationError(dict(amount='Invalid number.'))
# also prevents negative spending
if amount < 1.00: if amount < 1.00:
raise exceptions.ValidationError(dict(amount='Amount too small.')) raise exceptions.ValidationError(dict(amount='Amount too small.'))
@ -1112,7 +1298,7 @@ class ProtocoinViewSet(Base):
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0 source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
source_user_balance = float(source_user_balance) source_user_balance = float(source_user_balance)
if source_user_balance != balance: if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
raise exceptions.ValidationError(dict(balance='Incorrect current balance.')) raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
if source_user_balance < amount: if source_user_balance < amount:
@ -1175,6 +1361,38 @@ class ProtocoinViewSet(Base):
) )
return Response(res) return Response(res)
@action(detail=False, methods=['get'])
def printer_balance(self, request, pk=None):
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
#if secrets.VEND_API_TOKEN and auth_token != 'Bearer ' + secrets.VEND_API_TOKEN:
# raise exceptions.PermissionDenied()
track = cache.get('track', {})
track_graphics_computer = track.get('PROTOGRAPH1', None)
if not track_graphics_computer:
return Response(200)
track_username = track_graphics_computer['username']
track_time = track_graphics_computer['time']
try:
source_user = User.objects.get(username__iexact=track_username)
except User.DoesNotExist:
return Response(200)
if time.time() - track_time > 10:
return Response(200)
user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
user_balance = float(user_balance)
res = dict(
balance=user_balance,
first_name=source_user.member.preferred_name,
)
return Response(res)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def card_vend_request(self, request, pk=None): def card_vend_request(self, request, pk=None):
try: try:
@ -1186,6 +1404,8 @@ class ProtocoinViewSet(Base):
source_card = get_object_or_404(models.Card, card_number=pk) source_card = get_object_or_404(models.Card, card_number=pk)
source_user = source_card.user source_user = source_card.user
machine = request.data.get('machine', 'unknown')
try: try:
number = request.data['number'] number = request.data['number']
except KeyError: except KeyError:
@ -1205,6 +1425,7 @@ class ProtocoinViewSet(Base):
except ValueError: except ValueError:
raise exceptions.ValidationError(dict(amount='Invalid number.')) raise exceptions.ValidationError(dict(amount='Invalid number.'))
# also prevents negative spending
if amount < 0.25: if amount < 0.25:
raise exceptions.ValidationError(dict(amount='Amount too small.')) raise exceptions.ValidationError(dict(amount='Amount too small.'))
@ -1212,7 +1433,7 @@ class ProtocoinViewSet(Base):
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0 source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
source_user_balance = float(source_user_balance) source_user_balance = float(source_user_balance)
if source_user_balance != balance: if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
raise exceptions.ValidationError(dict(balance='Incorrect current balance.')) raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
if source_user_balance < amount: if source_user_balance < amount:
@ -1220,8 +1441,9 @@ class ProtocoinViewSet(Base):
source_delta = -amount source_delta = -amount
memo = 'Protocoin - Purchase spent ₱ {} on vending machine item #{}'.format( memo = 'Protocoin - Purchase spent ₱ {} on {} vending machine item #{}'.format(
amount, amount,
machine,
number, number,
) )
@ -1254,6 +1476,128 @@ class ProtocoinViewSet(Base):
) )
return Response(res) return Response(res)
@action(detail=False, methods=['post'])
def printer_report(self, request, pk=None):
try:
with transaction.atomic():
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
#if secrets.VEND_API_TOKEN and auth_token != 'Bearer ' + secrets.VEND_API_TOKEN:
# raise exceptions.PermissionDenied()
# {'job_name': 'download.png', 'uuid': '6abbad4d-dda3-4954-b4f1-ac77933a0562', 'timestamp': '20230211173624',
# 'job_status': '0', 'user_name': 'Tanner.Collin', 'source': '1', 'paper_name': 'Plain Paper', 'paper_sqi': '356', 'ink_ul': '54'}
job_uuid = request.data['uuid']
username = request.data['user_name']
logging.info('New printer job UUID: %s, username: %s', str(job_uuid), str(username))
if not job_uuid:
msg = 'Missing job UUID, aborting.'
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
tx = models.Transaction.objects.filter(reference_number=job_uuid)
if tx.exists():
msg = 'Job {}: already billed for in transaction {}, aborting.'.format(job_uuid, tx[0].id)
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
if not username:
msg = 'Job {}: missing username, aborting.'.format(job_uuid)
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
# status 0 = complete
# status 3 = cancelled
is_completed = request.data['job_status'] == '0'
is_print = request.data['source'] == '1'
if not is_completed:
msg = 'Job {} user {}: not complete, aborting.'.format(job_uuid, username)
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
if not is_print:
msg = 'Job {} user {}: not a print, aborting.'.format(job_uuid, username)
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
try:
user = User.objects.get(username__iexact=username)
except User.DoesNotExist:
msg = 'Job {}: unable to find username {}, aborting.'.format(job_uuid, username)
utils.alert_tanner(msg)
logger.error(msg)
return Response(200)
INK_PROTOCOIN_PER_ML = 0.75
DEFAULT_PAPER_PROTOCOIN_PER_M = 0.50
PROTOCOIN_PER_PRINT = 2.0
total_cost = PROTOCOIN_PER_PRINT
logging.info(' Fixed cost: %s', str(PROTOCOIN_PER_PRINT))
microliters = float(request.data['ink_ul'])
millilitres = microliters / 1000.0
cost = millilitres * INK_PROTOCOIN_PER_ML
total_cost += cost
logging.info(' %s ul ink cost: %s', str(microliters), str(cost))
PAPER_COSTS = {
'Plain Paper': 0.25,
}
squareinches = float(request.data['paper_sqi'])
squaremetres = squareinches / 1550.0
cost = squaremetres * PAPER_COSTS.get(request.data['paper_name'], DEFAULT_PAPER_PROTOCOIN_PER_M)
total_cost += cost
logging.info(' %s sqi paper cost: %s', str(squareinches), str(cost))
total_cost = round(total_cost, 2)
logging.info('Total cost: %s protocoin', str(total_cost))
memo = 'Protocoin - Purchase spent ₱ {} printing {}'.format(
total_cost,
request.data['job_name'],
)
tx = models.Transaction.objects.create(
user=user,
protocoin=-total_cost,
amount=0,
number_of_membership_months=0,
account_type='Protocoin',
category='Consumables',
info_source='System',
reference_number=job_uuid,
memo=memo,
)
utils.log_transaction(tx)
track = cache.get('track', {})
devicename = 'LASTLARGEPRINT'
first_name = username.split('.')[0].title()
track[devicename] = dict(
time=time.time(),
username=username,
first_name=first_name,
)
cache.set('track', track)
return Response(200)
except OperationalError:
self.printer_report(request, pk)
class PinballViewSet(Base): class PinballViewSet(Base):
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
@ -1303,6 +1647,143 @@ class PinballViewSet(Base):
return Response(200) return Response(200)
@action(detail=True, methods=['get'])
def get_name(self, request, pk=None):
auth_token = request.META.get('HTTP_AUTHORIZATION', '')
if secrets.PINBALL_API_TOKEN and auth_token != 'Bearer ' + secrets.PINBALL_API_TOKEN:
raise exceptions.PermissionDenied()
card = get_object_or_404(models.Card, card_number=pk)
member = card.user.member
res = dict(
name=member.preferred_name + ' ' + member.last_name[0]
)
return Response(res)
@action(detail=False, methods=['get'])
def high_scores(self, request):
members = models.Member.objects.all()
members = members.annotate(
pinball_score=Max('user__scores__score'),
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
scores = []
for member in members:
scores.append(dict(
name=member.preferred_name + ' ' + member.last_name[0],
score=member.pinball_score,
member_id=member.id,
))
return Response(scores)
@action(detail=False, methods=['get'])
def monthly_high_scores(self, request):
now = utils.now_alberta_tz()
current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
members = models.Member.objects.all()
members = members.annotate(
pinball_score=Max('user__scores__score', filter=Q(user__scores__finished_at__gte=current_month_start)),
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
scores = []
for member in members:
scores.append(dict(
name=member.preferred_name + ' ' + member.last_name[0],
score=member.pinball_score,
member_id=member.id,
))
return Response(scores)
class HostingViewSet(Base):
@action(detail=False, methods=['post'])
def offer(self, request):
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
#if secrets.PINBALL_API_TOKEN and auth_token != 'Bearer ' + secrets.PINBALL_API_TOKEN:
# raise exceptions.PermissionDenied()
try:
member_id = int(request.data['member_id'])
except KeyError:
raise exceptions.ValidationError(dict(game_id='This field is required.'))
except ValueError:
raise exceptions.ValidationError(dict(game_id='Invalid number.'))
try:
hours = int(request.data['hours'])
except KeyError:
raise exceptions.ValidationError(dict(player='This field is required.'))
except ValueError:
raise exceptions.ValidationError(dict(player='Invalid number.'))
hosting_member = get_object_or_404(models.Member, id=member_id)
hosting_user = hosting_member.user
logging.info('Hosting offer from %s %s for %s hours', hosting_member.preferred_name, hosting_member.last_name, hours)
try:
current_hosting = models.Hosting.objects.get(user=hosting_user, finished_at__gte=now())
logging.info('Current hosting by member: %s', current_hosting)
new_end = now() + datetime.timedelta(hours=hours)
new_delta = new_end - current_hosting.started_at
new_hours = new_delta.seconds / 3600
logging.info(
'Hosting %s from %s is still going, updating hours from %s to %s.',
current_hosting.id,
current_hosting.started_at,
current_hosting.hours,
new_hours
)
current_hosting.finished_at = new_end
current_hosting.hours = new_hours
current_hosting.save()
except models.Hosting.DoesNotExist:
h = models.Hosting.objects.create(
user=hosting_user,
hours=hours,
finished_at=now() + datetime.timedelta(hours=hours),
)
logging.info('No current hosting for that user, new hosting #%s created.', h.id)
# update "open until" time
hosting = models.Hosting.objects.order_by('-finished_at').first()
closing = dict(
time=hosting.finished_at.timestamp(),
time_str=hosting.finished_at.astimezone(utils.TIMEZONE_CALGARY).strftime('%-I:%M %p'),
first_name=hosting.user.member.preferred_name,
)
cache.set('closing', closing)
return Response(200)
@action(detail=False, methods=['get'])
def high_scores(self, request):
members = models.Member.objects.all()
members = members.annotate(
hosting_hours=Sum('user__hosting__hours'),
).exclude(hosting_hours__isnull=True).order_by('-hosting_hours')
hours = []
for member in members:
hours.append(dict(
name=member.preferred_name + ' ' + member.last_name[0],
hours=member.hosting_hours,
member_id=member.id,
))
return Response(hours)
class StorageSpaceViewSet(Base, List, Retrieve, Update): class StorageSpaceViewSet(Base, List, Retrieve, Update):
permission_classes = [AllowMetadata | IsAdmin] permission_classes = [AllowMetadata | IsAdmin]

View File

@ -217,7 +217,7 @@ if DEBUG:
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 300, 'PAGE_SIZE': 500,
'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES, 'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES,
'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES, 'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES,
'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'], 'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'],
@ -250,6 +250,11 @@ LOGGING = {
}, },
}, },
'loggers': { 'loggers': {
#'django.db.backends': {
# 'handlers': ['console'],
# 'level': 'DEBUG',
# 'propagate': False,
# },
'gunicorn': { 'gunicorn': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO', 'level': 'DEBUG' if DEBUG else 'INFO',

View File

@ -21,6 +21,7 @@ router.register(r'history', views.HistoryViewSet, basename='history')
router.register(r'vetting', views.VettingViewSet, basename='vetting') router.register(r'vetting', views.VettingViewSet, basename='vetting')
router.register(r'pinball', views.PinballViewSet, basename='pinball') router.register(r'pinball', views.PinballViewSet, basename='pinball')
router.register(r'storage', views.StorageSpaceViewSet, basename='storage') router.register(r'storage', views.StorageSpaceViewSet, basename='storage')
router.register(r'hosting', views.HostingViewSet, basename='hosting')
router.register(r'sessions', views.SessionViewSet, basename='session') router.register(r'sessions', views.SessionViewSet, basename='session')
router.register(r'training', views.TrainingViewSet, basename='training') router.register(r'training', views.TrainingViewSet, basename='training')
router.register(r'interest', views.InterestViewSet, basename='interest') router.register(r'interest', views.InterestViewSet, basename='interest')

View File

@ -73,4 +73,5 @@ typing-extensions==4.0.1
urllib3==1.25.11 urllib3==1.25.11
wcwidth==0.2.5 wcwidth==0.2.5
webencodings==0.5.1 webencodings==0.5.1
xmltodict==0.13.0
zipp==3.8.1 zipp==3.8.1

View File

@ -0,0 +1,32 @@
# will not work after expired date change
# =======================================
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
from dateutil import relativedelta
from apiserver.api import models
members = models.Member.objects.all()
count = 0
for m in members:
if m.paused_date and m.status == 'Former Member':
print('Former member', m.preferred_name, m.last_name)
if m.paused_date == m.expire_date:
new_status = 'Expired Member'
new_paused_date = m.paused_date + relativedelta.relativedelta(months=3)
print(' Moving paused date', m.paused_date, '-->', new_paused_date)
m.paused_date = new_paused_date
else:
new_status = 'Paused Member'
print(' Setting status to', new_status)
m.status = new_status
count += 1
m.save()
print('Processed', count)

View File

@ -8,7 +8,7 @@ from apiserver.api import models
sessions = models.Session.objects.filter(datetime__gte='2021-01-01') sessions = models.Session.objects.filter(datetime__gte='2021-01-01')
with open('output.csv', 'w', newline='') as csvfile: with open('output.csv', 'w', newline='') as csvfile:
fields = ['date', 'name', 'num_students'] fields = ['date', 'name', 'num_students','attended']
writer = csv.DictWriter(csvfile, fieldnames=fields) writer = csv.DictWriter(csvfile, fieldnames=fields)
writer.writeheader() writer.writeheader()
@ -17,6 +17,7 @@ with open('output.csv', 'w', newline='') as csvfile:
writer.writerow(dict( writer.writerow(dict(
date=s.datetime.date(), date=s.datetime.date(),
name=s.course.name, name=s.course.name,
num_students=s.students.filter(attendance_status='Attended').count(), num_students=s.students.count(),
attended=s.students.filter(attendance_status='Attended').count(),
)) ))

BIN
webclient/public/toast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -69,31 +69,27 @@ export function AdminVetting(props) {
const displayAll = (vetting && vetting.length <= 5) || showAll; const displayAll = (vetting && vetting.length <= 5) || showAll;
return ( return (
<div> <div className='adminvetting'>
{!error ? {!error ?
vetting ? vetting ?
<> <>
<Table collapsing basic='very'> <Table compact collapsing unstackable 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.HeaderCell>Status / NMO</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Start Date</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell> <Table.HeaderCell></Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{(displayAll ? vetting : vetting.slice(0,5)).map(x => {(displayAll ? vetting : vetting.slice(0,5)).sort((a, b) => a.last_name > b.last_name ? 1 : -1).map(x =>
<Table.Row key={x.id}> <Table.Row key={x.id}>
<Table.Cell><Link to={'/members/'+x.id}>{x.preferred_name} {x.last_name}</Link></Table.Cell> <Table.Cell><Link to={'/members/'+x.id}>{x.preferred_name} {x.last_name}</Link></Table.Cell>
<Table.Cell><a href={'mailto:'+x.email}>Email</a></Table.Cell>
<Table.Cell> <Table.Cell>
<Icon name='circle' color={statusColor[x.status]} /> <Icon name='circle' color={statusColor[x.status]} />
{x.status || 'Unknown'} {x.orientation_date ? '✅' : '❌'}
</Table.Cell> </Table.Cell>
<Table.Cell>{x.current_start_date}</Table.Cell>
<Table.Cell><AdminVet {...props} member={x} refreshVetting={refreshVetting} /></Table.Cell> <Table.Cell><AdminVet {...props} member={x} refreshVetting={refreshVetting} /></Table.Cell>
</Table.Row> </Table.Row>
)} )}
@ -344,6 +340,7 @@ export function Admin(props) {
<Header size='medium'>Ready to Vet</Header> <Header size='medium'>Ready to Vet</Header>
<p>Members who are Current or Due, and past their probationary period.</p> <p>Members who are Current or Due, and past their probationary period.</p>
<p>Sorted by last name.</p>
<AdminVetting {...props} /> <AdminVetting {...props} />

View File

@ -124,7 +124,7 @@ let prevAutoscan = '';
export function AdminMemberCards(props) { export function AdminMemberCards(props) {
const { token, result, refreshResult } = props; const { token, result, refreshResult } = props;
const cards = result.cards; const cards = result.cards;
const startDimmed = Boolean((result.member.paused_date || !result.member.is_allowed_entry) && cards.length); const startDimmed = Boolean((result.member.paused_date || !result.member.is_allowed_entry || !result.member.vetted_date) && cards.length);
const [dimmed, setDimmed] = useState(startDimmed); const [dimmed, setDimmed] = useState(startDimmed);
const [input, setInput] = useState({ active_status: 'card_active' }); const [input, setInput] = useState({ active_status: 'card_active' });
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -134,7 +134,7 @@ export function AdminMemberCards(props) {
const { id } = useParams(); const { id } = useParams();
useEffect(() => { useEffect(() => {
const startDimmed = Boolean((result.member.paused_date || !result.member.is_allowed_entry) && cards.length); const startDimmed = Boolean((result.member.paused_date || !result.member.is_allowed_entry || !result.member.vetted_date) && cards.length);
setDimmed(startDimmed); setDimmed(startDimmed);
}, [result.member]); }, [result.member]);
@ -298,7 +298,7 @@ export function AdminMemberCards(props) {
<Dimmer active={dimmed}> <Dimmer active={dimmed}>
<p> <p>
Member paused or not allowed entry, {cards.length} card{cards.length === 1 ? '' : 's'} ignored anyway. Member paused, unvetted or not allowed entry. {cards.length} card{cards.length === 1 ? '' : 's'} ignored anyway.
</p> </p>
<p> <p>
<Button size='tiny' onClick={() => setDimmed(false)}>Close</Button> <Button size='tiny' onClick={() => setDimmed(false)}>Close</Button>
@ -363,18 +363,77 @@ export function AdminMemberPause(props) {
<div> <div>
<Header size='medium'>Pause / Unpause Membership</Header> <Header size='medium'>Pause / Unpause Membership</Header>
<p>Pause members who are inactive, former, or on vacation.</p>
<p> <p>
{result.member.paused_date ? {result.member.paused_date ?
<Button onClick={handleUnpause} loading={loading}> result.member.vetted_date && moment().diff(moment(result.member.paused_date), 'days') > 370 ?
Unpause <>
</Button> <p>
{result.member.preferred_name} has been away for more than a year and will need to be re-vetted according to our
<a href='https://wiki.protospace.ca/Approved_policies/Membership' target='_blank' rel='noopener noreferrer'> policy</a>.
</p>
<p>
<Form.Checkbox
name='told1'
value={told1}
label='Told member to get re-vetted'
required
onChange={(e, v) => setTold1(v.checked)}
/>
</p>
<p>
<Form.Checkbox
name='told2'
value={told2}
label='Collected payment for member dues'
required
onChange={(e, v) => setTold2(v.checked)}
/>
</p>
<Button onClick={handleUnpause} loading={loading} disabled={!told1 || !told2}>
Unpause
</Button>
</>
:
result.member.status == 'Expired Member' ?
<>
<p>
{result.member.preferred_name} has expired due to lapse of payment.
</p>
<p>
<Form.Checkbox
name='told1'
value={told1}
label='Member has paid any back-dues owed'
required
onChange={(e, v) => setTold1(v.checked)}
/>
</p>
<p>
<Form.Checkbox
name='told2'
value={told2}
label='Recorded payment transaction on portal'
required
onChange={(e, v) => setTold2(v.checked)}
/>
</p>
<Button onClick={handleUnpause} loading={loading} disabled={!told1 || !told2}>
Unpause
</Button>
</>
:
<Button onClick={handleUnpause} loading={loading}>
Unpause
</Button>
: :
<> <>
<p>Pause members who are inactive, former, or on vacation.</p>
<p> <p>
<Form.Checkbox <Form.Checkbox
name='told_subscriptions' name='told1'
value={told1} value={told1}
label='Told member to stop any PayPal subscriptions' label='Told member to stop any PayPal subscriptions'
required required
@ -383,7 +442,7 @@ export function AdminMemberPause(props) {
</p> </p>
<p> <p>
<Form.Checkbox <Form.Checkbox
name='told_shelves' name='told2'
value={told2} value={told2}
label='Told member to clear any shelves' label='Told member to clear any shelves'
required required

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import './light.css'; import './light.css';
import { Container, Checkbox, Form, Header, Segment } from 'semantic-ui-react'; import { Container, Checkbox, Form, Header, Segment, Table } from 'semantic-ui-react';
import * as Datetime from 'react-datetime'; import * as Datetime from 'react-datetime';
import 'react-datetime/css/react-datetime.css'; import 'react-datetime/css/react-datetime.css';
import moment from 'moment'; import moment from 'moment';
@ -42,28 +42,25 @@ export function AdminReportedTransactions(props) {
}; };
let transactionsCache = false; let transactionsCache = false;
let excludePayPalCache = false; let summaryCache = false;
export function AdminHistoricalTransactions(props) { export function AdminHistoricalTransactions(props) {
const { token } = props; const { token } = props;
const [input, setInput] = useState({ month: moment() }); const [input, setInput] = useState({ month: moment() });
const [transactions, setTransactions] = useState(transactionsCache); const [transactions, setTransactions] = useState(transactionsCache);
const [excludePayPal, setExcludePayPal] = useState(excludePayPalCache); const [summary, setSummary] = useState(summaryCache);
const [excludePayPal, setExcludePayPal] = useState(false);
const [excludeSnacks, setExcludeSnacks] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const handleDatetime = (v) => setInput({ ...input, month: v }); const handleDatetime = (v) => setInput({ ...input, month: v });
const handleExcludePayPal = (e, v) => { const makeRequest = () => {
setExcludePayPal(v.checked);
excludePayPalCache = v.checked;
};
const handleSubmit = (e) => {
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
const month = input.month.format('YYYY-MM'); const month = input.month.format('YYYY-MM');
requester('/transactions/?month=' + month, 'GET', token) requester('/transactions/?month=' + month + '&exclude_paypal=' + excludePayPal + '&exclude_snacks=' + excludeSnacks, 'GET', token)
.then(res => { .then(res => {
setLoading(false); setLoading(false);
setError(false); setError(false);
@ -75,8 +72,37 @@ export function AdminHistoricalTransactions(props) {
console.log(err); console.log(err);
setError(true); setError(true);
}); });
requester('/transactions/summary/?month=' + month, 'GET', token)
.then(res => {
setLoading(false);
setError(false);
setSummary(res);
summaryCache = res;
})
.catch(err => {
setLoading(false);
console.log(err);
setError(true);
});
}; };
const handleSubmit = (e) => {
makeRequest();
};
const handleExcludePayPal = (e, v) => {
setExcludePayPal(v.checked);
};
const handleExcludeSnacks = (e, v) => {
setExcludeSnacks(v.checked);
};
useEffect(() => {
makeRequest();
}, [excludePayPal, excludeSnacks]);
return ( return (
<div> <div>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
@ -96,21 +122,59 @@ export function AdminHistoricalTransactions(props) {
</Form.Button> </Form.Button>
</Form.Group> </Form.Group>
</Form> </Form>
{transactions && <p>Found {transactions.length} transactions.</p>}
{!error ?
summary && <div>
<Header size='small'>Summary</Header>
<Table basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Category</Table.HeaderCell>
<Table.HeaderCell>Dollar</Table.HeaderCell>
<Table.HeaderCell>Protocoin</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{summary.map(x =>
<Table.Row key={x.category}>
<Table.Cell>{x.category}</Table.Cell>
<Table.Cell>{'$' + x.dollar.toFixed(2)}</Table.Cell>
<Table.Cell>{'₱ ' + x.protocoin.toFixed(2)}</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
:
<p>Error loading summary.</p>
}
<p/>
{!error ? {!error ?
transactions && <div> transactions && <div>
<p>Found {transactions.length} transactions.</p>
{!!transactions.length && {!!transactions.length &&
<Header size='small'>{moment(transactions[0].date, 'YYYY-MM-DD').format('MMMM YYYY')} Transactions</Header> <Header size='small'>{moment(transactions[0].date, 'YYYY-MM-DD').format('MMMM YYYY')} Transactions</Header>
} }
<Checkbox <Checkbox
className='filter-option'
label='Exclude PayPal' label='Exclude PayPal'
onChange={handleExcludePayPal} onChange={handleExcludePayPal}
checked={excludePayPal} checked={excludePayPal}
/> />
<TransactionList transactions={transactions.filter(x => !excludePayPal || x.account_type !== 'PayPal')} /> <Checkbox
className='filter-option'
label='Exclude Snacks'
onChange={handleExcludeSnacks}
checked={excludeSnacks}
/>
<TransactionList transactions={transactions} />
</div> </div>
: :
<p>Error loading transactions.</p> <p>Error loading transactions.</p>

View File

@ -29,7 +29,7 @@ import { NotFound, PleaseLogin } from './Misc.js';
import { Debug } from './Debug.js'; import { Debug } from './Debug.js';
import { Garden } from './Garden.js'; import { Garden } from './Garden.js';
import { Footer } from './Footer.js'; import { Footer } from './Footer.js';
import { LCARS1Display } from './Display.js'; import { LCARS1Display, LCARS2Display } from './Display.js';
const APP_VERSION = 5; // TODO: automate this const APP_VERSION = 5; // TODO: automate this
@ -98,7 +98,8 @@ function App() {
right: '16px', right: '16px',
buttonColorDark: '#666', buttonColorDark: '#666',
buttonColorLight: '#aaa', buttonColorLight: '#aaa',
label: '🌙', label: '🌓',
autoMatchOsTheme: false,
} }
const darkmode = new Darkmode(options); const darkmode = new Darkmode(options);
darkmode.showWidget(); darkmode.showWidget();
@ -129,6 +130,10 @@ function App() {
<LCARS1Display token={token} /> <LCARS1Display token={token} />
</Route> </Route>
<Route exact path='/display/lcars2'>
<LCARS2Display token={token} />
</Route>
<Route path='/'> <Route path='/'>
<Container> <Container>
<div className='hero'> <div className='hero'>

View File

@ -20,8 +20,8 @@ export function Cards(props) {
{user.member.card_photo ? {user.member.card_photo ?
<p> <p>
<a href={staticUrl + '/' + user.member.card_photo} target='_blank'> <a href={staticUrl + '/' + user.member.card_photo} target='_blank'>
Click here View your card image.
</a> to view your card image. </a>
</p> </p>
: :
<p>Upload a photo to generate a card image.</p> <p>Upload a photo to generate a card image.</p>

View File

@ -310,7 +310,7 @@ export function Charts(props) {
<XAxis dataKey='date' minTickGap={10} /> <XAxis dataKey='date' minTickGap={10} />
<YAxis /> <YAxis />
<CartesianGrid strokeDasharray='3 3'/> <CartesianGrid strokeDasharray='3 3'/>
<Tooltip /> <Tooltip labelFormatter={t => moment(t).format('YYYY-MM-DD ddd')} />
<Legend /> <Legend />
<Bar <Bar

View File

@ -7,6 +7,7 @@ import { apiUrl, isAdmin, getInstructor, BasicTable, requester, useIsMobile } fr
import { NotFound } from './Misc.js'; import { NotFound } 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 { PayWithProtocoin } from './Paymaster.js';
import { tags } from './Courses.js'; import { tags } from './Courses.js';
function ClassTable(props) { function ClassTable(props) {
@ -298,6 +299,8 @@ export function ClassFeed(props) {
: :
<p>Loading...</p> <p>Loading...</p>
} }
<p style={{ marginBottom: '30rem' }}/>
</Container> </Container>
); );
}; };
@ -691,6 +694,20 @@ export function ClassDetail(props) {
name={clazz.course_data.name} name={clazz.course_data.name}
custom={JSON.stringify({ training: userTraining.id })} custom={JSON.stringify({ training: userTraining.id })}
/> />
<p/>
<p>Current balance: &thinsp;{user.member.protocoin.toFixed(2)}</p>
<PayWithProtocoin
token={token} user={user} refreshUser={refreshUser}
amount={clazz.cost}
onSuccess={() => {
refreshUser();
refreshClass();
}}
custom={{ category: 'OnAcct', training: userTraining.id }}
/>
</div> </div>
} }
</div> </div>

View File

@ -235,7 +235,7 @@ export function CourseDetail(props) {
<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).map(x =>
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}> <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}>

View File

@ -26,6 +26,10 @@ export function Debug(props) {
<p><Link to='/usage/trotec'>Trotec Usage</Link></p> <p><Link to='/usage/trotec'>Trotec Usage</Link></p>
<p><Link to='/display/lcars1'>LCARS1 Display</Link></p>
<p><Link to='/display/lcars2'>LCARS2 Display</Link></p>
</Container> </Container>
); );

View File

@ -34,7 +34,17 @@ export function LCARS1Display(props) {
</p> </p>
} }
<div></div> <div className='display-scores'>
<DisplayScores />
</div>
<div className='display-scores'>
<DisplayMonthlyScores />
</div>
<div className='display-scores'>
<DisplayHosting />
</div>
<div className='display-usage'> <div className='display-usage'>
<DisplayUsage token={token} name={'trotec'} /> <DisplayUsage token={token} name={'trotec'} />
@ -44,6 +54,43 @@ export function LCARS1Display(props) {
); );
}; };
export function LCARS2Display(props) {
const { token } = props;
const [fullElement, setFullElement] = useState(false);
const ref = useRef(null);
const goFullScreen = () => {
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen');
}
ref.current.requestFullscreen({ navigationUI: 'hide' }).then(() => {
setFullElement(true);
});
};
return (
<Container>
<div className='display' ref={ref}>
{!fullElement &&
<p>
<Button onClick={goFullScreen}>Fullscreen</Button>
</p>
}
<div className='display-scores'>
<DisplayScores />
</div>
<div className='display-scores'>
<DisplayHosting />
</div>
</div>
</Container>
);
};
export function DisplayUsage(props) { export function DisplayUsage(props) {
const { token, name } = props; const { token, name } = props;
const title = deviceNames[name].title; const title = deviceNames[name].title;
@ -67,21 +114,128 @@ export function DisplayUsage(props) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const showUsage = usage && usage.track.username === usage.username; const inUse = usage && moment().unix() - usage.track.time <= 60;
const showUsage = usage && inUse && usage.track.username === usage.username;
return ( return (
<> <>
<Header size='large'>Trotec Usage</Header>
{showUsage ? {showUsage ?
<TrotecUsage usage={usage} /> <TrotecUsage usage={usage} />
: :
<> <p className='stat'>
<Header size='medium'>Trotec Usage</Header> Waiting for job
</p>
<p className='stat'>
Waiting for job
</p>
</>
} }
</> </>
); );
}; };
export function DisplayScores(props) {
const { token, name } = props;
const [scores, setScores] = useState(false);
const getScores = () => {
requester('/pinball/high_scores/', 'GET')
.then(res => {
setScores(res);
})
.catch(err => {
console.log(err);
setScores(false);
});
};
useEffect(() => {
getScores();
const interval = setInterval(getScores, 60000);
return () => clearInterval(interval);
}, []);
return (
<>
<Header size='large'>Pinball High Scores</Header>
{scores && scores.slice(0, 5).map((x, i) =>
<div key={i}>
<Header size='medium'>#{i+1} {x.name}. {i === 0 ? '👑' : ''}</Header>
<p>{x.score.toLocaleString()}</p>
</div>
)}
</>
);
};
export function DisplayMonthlyScores(props) {
const { token, name } = props;
const [scores, setScores] = useState(false);
const getScores = () => {
requester('/pinball/monthly_high_scores/', 'GET')
.then(res => {
setScores(res);
})
.catch(err => {
console.log(err);
setScores(false);
});
};
useEffect(() => {
getScores();
const interval = setInterval(getScores, 60000);
return () => clearInterval(interval);
}, []);
return (
<>
<Header size='large'>Monthly High Scores</Header>
{scores && scores.slice(0, 5).map((x, i) =>
<div key={i}>
<Header size='medium'>#{i+1} {x.name}. {i === 0 ? '🧙' : ''}</Header>
<p>{x.score.toLocaleString()}</p>
</div>
)}
</>
);
};
export function DisplayHosting(props) {
const { token, name } = props;
const [scores, setScores] = useState(false);
const getScores = () => {
requester('/hosting/high_scores/', 'GET')
.then(res => {
setScores(res);
})
.catch(err => {
console.log(err);
setScores(false);
});
};
useEffect(() => {
getScores();
const interval = setInterval(getScores, 60000);
return () => clearInterval(interval);
}, []);
return (
<>
<Header size='large'>Most Host</Header>
{scores && scores.slice(0, 5).map((x, i) =>
<div key={i}>
<Header size='medium'>#{i+1} {x.name}. {i === 0 ? <img className='toast' src='/toast.png' /> : ''}</Header>
<p>{x.hours.toFixed(2)} hours</p>
</div>
)}
</>
);
};

View File

@ -49,9 +49,9 @@ export const Footer = () => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Click here View the source code and license on GitHub.
</a>{' '} </a>{' '}
to view the source code and license.
</p> </p>
<p> <p>
@ -97,7 +97,7 @@ export const Footer = () => {
</a> </a>
</p> </p>
<p>© 2020 Calgary Protospace Ltd.</p> <p>© 2020-{new Date().getFullYear()} Calgary Protospace Ltd.</p>
</Container> </Container>
</div> </div>
); );

View File

@ -22,6 +22,11 @@ function MemberInfo(props) {
return ( return (
<div> <div>
{member.protocoin < 0 && <Message error>
<Message.Header>Your Protocoin balance is negative!</Message.Header>
<p>Visit the <Link to='/paymaster'>Paymaster</Link> page or pay a Director to buy Protocoin.</p>
</Message>}
<Grid stackable> <Grid stackable>
<Grid.Column width={5}> <Grid.Column width={5}>
<Image <Image
@ -65,8 +70,8 @@ function MemberInfo(props) {
<Message.Header>Welcome, new member!</Message.Header> <Message.Header>Welcome, new member!</Message.Header>
<p> <p>
<a href={staticUrl + '/' + member.member_forms} target='_blank'> <a href={staticUrl + '/' + member.member_forms} target='_blank'>
Click here View your application forms.
</a> to view your application forms. </a>
</p> </p>
</Message>} </Message>}
@ -89,8 +94,6 @@ function MemberInfo(props) {
<QRCode value={siteUrl + 'subscribe?monthly_fees=' + user.member.monthly_fees + '&id=' + user.member.id} /> <QRCode value={siteUrl + 'subscribe?monthly_fees=' + user.member.monthly_fees + '&id=' + user.member.id} />
</React.Fragment>} </React.Fragment>}
<Header size='medium'>Latest Training</Header>
{unpaidTraining.map(x => {unpaidTraining.map(x =>
<Message warning> <Message warning>
<Message.Header>Please pay your course fee!</Message.Header> <Message.Header>Please pay your course fee!</Message.Header>
@ -98,6 +101,12 @@ function MemberInfo(props) {
</Message> </Message>
)} )}
<Header size='medium'>Latest Training</Header>
{!member.orientation_date && <p>
You need to attend a <Link to={'/courses/249/'}>New Member Orientation</Link> to use any tool larger than a screwdriver.
</p>}
<BasicTable> <BasicTable>
<Table.Body> <Table.Body>
{lastTrain.length ? {lastTrain.length ?
@ -110,7 +119,7 @@ function MemberInfo(props) {
</Table.Row> </Table.Row>
) )
: :
<Table.Row><Table.Cell>None, please sign up for an <Link to={'/courses/249/'}>Orientation</Link></Table.Cell></Table.Row> <Table.Row><Table.Cell>None</Table.Cell></Table.Row>
} }
{user.training.length > 3 && {user.training.length > 3 &&
<Table.Row><Table.Cell> <Table.Row><Table.Cell>
@ -231,9 +240,15 @@ export function Home(props) {
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : ''; const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : '';
const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['first_name'] ? stats.track[x]['first_name'] : 'Unknown'; const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['first_name'] ? stats.track[x]['first_name'] : 'Unknown';
const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] < 270 ? 'Armed' : 'Disarmed' : 'Unknown'; //const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] < 270 ? 'Armed' : 'Disarmed' : 'Unknown';
const alarmStat = () => 'Unknown';
const doorOpenStat = () => alarmStat() === 'Disarmed' && stats.alarm['data'] > 360 ? ', door open' : ''; //const doorOpenStat = () => alarmStat() === 'Disarmed' && stats.alarm['data'] > 360 ? ', door open' : '';
const doorOpenStat = () => '';
const closedStat = (x) => stats && stats.closing ? moment().unix() > stats.closing['time'] ? 'Closed' : 'Open until ' + stats.closing['time_str'] : 'Unknown';
const printer3dStat = (x) => stats && stats.printer3d && stats.printer3d[x] ? stats.printer3d[x].state === 'Printing' ? 'Printing (' + stats.printer3d[x].progress + '%)' : stats.printer3d[x].state : 'Unknown';
const show_signup = stats?.at_protospace; const show_signup = stats?.at_protospace;
@ -261,8 +276,8 @@ export function Home(props) {
{user?.member?.set_details !== false && {user?.member?.set_details !== false &&
<Segment> <Segment>
<Header size='medium'>Quick Links</Header> <Header size='medium'>Quick Links</Header>
<p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p> <p><a href='https://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='https://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'>Forum (Spacebar)</a> <Link to='/auth/discourse'>[register]</Link></p> <p><a href='https://forum.protospace.ca' target='_blank' rel='noopener noreferrer'>Forum (Spacebar)</a> <Link to='/auth/discourse'>[register]</Link></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 && <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>}
@ -348,7 +363,33 @@ export function Home(props) {
} trigger={<a>[more]</a>} /> } trigger={<a>[more]</a>} />
</p> </p>
<p>
Media computer: {getTrackStat('PROTOGRAPH1')} <Popup content={
<React.Fragment>
<p>
Last use:<br />
{getTrackLast('PROTOGRAPH1')}<br />
{getTrackAgo('PROTOGRAPH1')}<br />
by {getTrackName('PROTOGRAPH1')}
</p>
<p>
Last print:<br />
{getTrackLast('LASTLARGEPRINT')}<br />
{getTrackAgo('LASTLARGEPRINT')}<br />
by {getTrackName('LASTLARGEPRINT')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
<p>ORD2 printer: {printer3dStat('ord2')}</p>
<p>ORD3 printer: {printer3dStat('ord3')}</p>
{user && <p>Alarm status: {alarmStat()}{doorOpenStat()}</p>} {user && <p>Alarm status: {alarmStat()}{doorOpenStat()}</p>}
{user && <p>Hosting status: {closedStat()}</p>}
</div> </div>
<SignForm token={token} /> <SignForm token={token} />

View File

@ -230,7 +230,7 @@ export function Members(props) {
</Item.Header> </Item.Header>
{sort === 'pinball_score' ? {sort === 'pinball_score' ?
<> <>
<Item.Description>Score: {x.member.pinball_score || 'Unknown'}</Item.Description> <Item.Description>Score: {x.member.pinball_score.toLocaleString() || 'Unknown'}</Item.Description>
<Item.Description>Rank: {i === 0 ? 'Pinball Wizard' : 'Not the Pinball Wizard'}</Item.Description> <Item.Description>Rank: {i === 0 ? 'Pinball Wizard' : 'Not the Pinball Wizard'}</Item.Description>
</> </>
: :

View File

@ -6,6 +6,46 @@ import { PayPalPayNow, PayPalSubscribe } from './PayPal.js';
import { MembersDropdown } from './Members.js'; import { MembersDropdown } from './Members.js';
import { requester } from './utils.js'; import { requester } from './utils.js';
export function PayWithProtocoin(props) {
const { token, user, refreshUser, amount, onSuccess, custom } = props;
const member = user.member;
const [error, setError] = useState({});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = (e) => {
if (loading) return;
setSuccess(false);
setLoading(true);
const data = { amount: amount, ...custom, balance: member.protocoin };
requester('/protocoin/spend_request/', 'POST', token, data)
.then(res => {
setLoading(false);
setSuccess(true);
if (onSuccess) {
onSuccess();
}
setError({});
refreshUser();
})
.catch(err => {
setLoading(false);
console.log(err);
setError(err.data);
});
};
return (
<Form onSubmit={handleSubmit}>
<Form.Button disabled={!amount} color='green' loading={loading} error={error.amount}>
Pay with Protocoin
</Form.Button>
{success && <div>Success!</div>}
</Form>
);
};
export function SendProtocoin(props) { export function SendProtocoin(props) {
const { token, user, refreshUser } = props; const { token, user, refreshUser } = props;
const member = user.member; const member = user.member;
@ -76,10 +116,10 @@ export function Paymaster(props) {
const { token, user, refreshUser } = props; const { token, user, refreshUser } = 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 [consumables, setConsumables] = useState('');
const [buyProtocoin, setBuyProtocoin] = useState('10.00'); const [buyProtocoin, setBuyProtocoin] = useState('10.00');
const [consumablesMemo, setConsumablesMemo] = useState(''); const [consumablesMemo, setConsumablesMemo] = useState('');
const [donate, setDonate] = useState('20.00'); const [donate, setDonate] = useState('');
const [memo, setMemo] = useState(''); const [memo, setMemo] = useState('');
const monthly_fees = user.member.monthly_fees || 55; const monthly_fees = user.member.monthly_fees || 55;
@ -156,12 +196,12 @@ export function Paymaster(props) {
</Grid.Column> </Grid.Column>
</Grid> </Grid>
<Header size='medium'>Consumables</Header> <Grid stackable columns={2}>
<p>Pay for materials you use (ie. welding gas, 3D printing, blades, etc).</p>
<Grid stackable padded columns={1}>
<Grid.Column> <Grid.Column>
<Header size='medium'>Consumables</Header>
<p>Pay for materials you use (ie. welding gas, 3D printing, etc).</p>
Custom amount: Custom amount:
<div className='pay-custom'> <div className='pay-custom'>
@ -188,13 +228,21 @@ export function Paymaster(props) {
name='Protospace Consumables' name='Protospace Consumables'
custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })} custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })}
/> />
<p/>
<PayWithProtocoin
token={token} user={user} refreshUser={refreshUser}
amount={consumables}
onSuccess={() => setConsumables('')}
custom={{ category: 'Consumables', memo: consumablesMemo }}
/>
</Grid.Column> </Grid.Column>
</Grid>
<Header size='medium'>Donate</Header>
<Grid stackable padded columns={1}>
<Grid.Column> <Grid.Column>
<Header size='medium'>Donate</Header>
<p>Donation of any amount to Protospace.</p>
Custom amount: Custom amount:
<div className='pay-custom'> <div className='pay-custom'>
@ -221,6 +269,15 @@ export function Paymaster(props) {
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 })}
/> />
<p/>
<PayWithProtocoin
token={token} user={user} refreshUser={refreshUser}
amount={donate}
onSuccess={() => setDonate('')}
custom={{ category: 'Donation', memo: memo }}
/>
</Grid.Column> </Grid.Column>
</Grid> </Grid>

View File

@ -211,66 +211,6 @@ function EditTransaction(props) {
); );
}; };
function ReportTransaction(props) {
const { transaction, token, refreshUser } = props;
const [input, setInput] = useState(transaction);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const { id } = useParams();
const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value });
const handleChange = (e) => handleValues(e, e.currentTarget);
const handleSubmit = (e) => {
if (loading) return;
setLoading(true);
setSuccess(false);
requester('/transactions/'+id+'/report/', 'POST', token, input)
.then(res => {
setLoading(false);
setSuccess(true);
setError(false);
if (refreshUser) {
refreshUser();
}
})
.catch(err => {
setLoading(false);
console.log(err);
setError(err.data);
});
};
const makeProps = (name) => ({
name: name,
onChange: handleChange,
value: input[name] || '',
error: error[name],
});
return (
<div>
<Header size='medium'>Report Transaction</Header>
<p>If this transaction was made in error or there is anything incorrect about it, please report it using this form.</p>
<p>A staff member will review the report as soon as possible.</p>
<p>Follow up with <a href='mailto:directors@protospace.ca' target='_blank' rel='noopener noreferrer'>directors@protospace.ca</a>.</p>
<Form onSubmit={handleSubmit}>
<Form.TextArea
label='Reason'
{...makeProps('report_memo')}
/>
<Form.Button loading={loading} error={error.non_field_errors}>
Submit Report
</Form.Button>
{success && <div>Success!</div>}
</Form>
</div>
);
};
export function TransactionList(props) { export function TransactionList(props) {
const { transactions, noMember, noCategory } = props; const { transactions, noMember, noCategory } = props;
@ -474,7 +414,11 @@ export function TransactionDetail(props) {
</Segment> </Segment>
: :
<Segment padded> <Segment padded>
<ReportTransaction transaction={transaction} setTransaction={setTransaction} {...props} /> <Header size='medium'>Report Transaction</Header>
<p>If there's anything wrong with this transaction or it was made in error please email the Protospace Directors:</p>
<p><a href='mailto:directors@protospace.ca' target='_blank' rel='noopener noreferrer'>directors@protospace.ca</a></p>
<p>Please include a link to this transaction and any relevant details.</p>
</Segment> </Segment>
} }
</Grid.Column> </Grid.Column>

View File

@ -157,6 +157,11 @@ body {
margin-left: auto; margin-left: auto;
} }
.adminvetting .ui.button {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.interest .ui.button { .interest .ui.button {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
@ -184,15 +189,34 @@ body {
height: 100vh; height: 100vh;
background-color: black; background-color: black;
color: white; color: white;
font-size: 1em; font-size: 1.75em;
} }
.display-usage { .display-usage {
border: 1px solid white; border: 1px solid white;
padding: 0.5em; padding: 0.5em;
align-self: flex-end; align-self: flex-end;
width: 240px; width: 25vw;
height: 383px; height: 75vh;
}
.display-scores {
border: 1px solid white;
padding: 0.5em;
align-self: flex-end;
width: 25vw;
height: 75vh;
}
.display-scores p {
font-size: 1.5em;
text-align: right;
}
.display .display-scores .toast {
width: 40px;
height: 40px;
margin-bottom: 15px;
} }
.usage { .usage {
@ -222,6 +246,10 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.filter-option {
margin-right: 1rem;
}
.footer { .footer {
margin-top: -20rem; margin-top: -20rem;

View File

@ -30,6 +30,8 @@ export const statusColor = {
'Due': 'yellow', 'Due': 'yellow',
'Overdue': 'red', 'Overdue': 'red',
'Former Member': 'black', 'Former Member': 'black',
'Paused Member': 'black',
'Expired Member': 'black',
}; };
export const BasicTable = (props) => ( export const BasicTable = (props) => (