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
try:
if hasattr(model, 'MY_FIELDS'):
MyAdmin.list_display = model.MY_FIELDS
MyAdmin.search_fields = model.MY_FIELDS
if hasattr(model, 'list_display'):
MyAdmin.list_display = model.list_display
if hasattr(model, 'search_fields'):
MyAdmin.search_fields = model.search_fields
admin.site.register(model, MyAdmin)
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.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
@ -33,6 +34,132 @@ class Command(BaseCommand):
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):
self.stdout.write('{} - Beginning hourly tasks'.format(str(now())))
@ -41,6 +168,12 @@ class Command(BaseCommand):
self.generate_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(
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 import register
TIMEZONE_CALGARY = pytz.timezone('America/Edmonton')
register(User)
IGNORE = '+'
def today_alberta_tz():
return datetime.now(pytz.timezone('America/Edmonton')).date()
return datetime.now(TIMEZONE_CALGARY).date()
class Member(models.Model):
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'])
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):
return self.user.username
return getattr(self.user, 'username', 'None')
class Transaction(models.Model):
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)
date = models.DateField(default=today_alberta_tz)
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)
number_of_membership_months = models.IntegerField(blank=True, null=True)
payment_method = models.TextField(blank=True, null=True)
@ -89,7 +92,8 @@ class Transaction(models.Model):
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):
return '%s tx %s' % (user.username, date)
@ -101,7 +105,8 @@ class PayPalHint(models.Model):
history = HistoricalRecords()
MY_FIELDS = ['account', 'user']
list_display = ['account', 'user']
search_fields = ['account', 'user__username']
def __str__(self):
return self.account
@ -112,7 +117,8 @@ class IPN(models.Model):
history = HistoricalRecords()
MY_FIELDS = ['datetime', 'status']
list_display = ['datetime', 'status']
search_fields = ['datetime', 'status']
def __str__(self):
return self.datetime
@ -128,7 +134,8 @@ class Card(models.Model):
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):
return self.card_number
@ -140,7 +147,8 @@ class Course(models.Model):
history = HistoricalRecords()
MY_FIELDS = ['name', 'id']
list_display = ['name', 'id']
search_fields = ['name', 'id']
def __str__(self):
return self.name
@ -156,9 +164,10 @@ class Session(models.Model):
history = HistoricalRecords()
MY_FIELDS = ['datetime', 'course', 'instructor']
list_display = ['datetime', 'course', 'instructor']
search_fields = ['datetime', 'course__name', 'instructor__username']
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):
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()
MY_FIELDS = ['session', 'user']
list_display = ['session', 'user']
search_fields = ['session__course__name', 'user__username']
def __str__(self):
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)
MY_FIELDS = ['user', 'course', 'satisfied_by']
list_display = ['user', 'course', 'satisfied_by']
search_fields = ['user__username', 'course__name']
def __str__(self):
return '%s interested in %s' % (self.user, self.course)
@ -197,7 +208,8 @@ class StatsMemberCount(models.Model):
vetted_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):
month = models.DateField()
@ -205,13 +217,15 @@ class StatsSignupCount(models.Model):
retain_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):
date = models.DateField(default=today_alberta_tz)
card_scans = models.IntegerField()
MY_FIELDS = ['date', 'card_scans']
list_display = ['date', 'card_scans']
search_fields = ['date', 'card_scans']
class Usage(models.Model):
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)
started_at = models.DateTimeField(auto_now_add=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_reports = models.IntegerField()
@ -230,9 +244,10 @@ class Usage(models.Model):
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):
return self.started_at
return str(self.started_at)
class PinballScore(models.Model):
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
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):
return str(self.started_at)
@ -284,7 +314,8 @@ class HistoryIndex(models.Model):
is_system = 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):
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()
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):
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
new_protocoin = current_protocoin + validated_data['protocoin']
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)
@ -633,10 +634,23 @@ class SessionSerializer(serializers.ModelSerializer):
else:
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):
if not self.initial_data.get('instructor_id', None):
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'])
if not (is_admin_director(member.user) or member.is_instructor):
raise ValidationError(dict(instructor_id='Member is not an instructor.'))
@ -672,14 +686,15 @@ class CourseDetailSerializer(serializers.ModelSerializer):
continue
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):
if date > utils.today_alberta_tz():
if date > start:
return date
raise
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)
if recent_sessions.count() < 3:
return True
@ -695,9 +710,14 @@ class CourseDetailSerializer(serializers.ModelSerializer):
dt = utils.TIMEZONE_CALGARY.localize(dt)
cost = 0
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
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)
else:
date = next_date(calendar.THURSDAY, week_num=3)
@ -745,6 +765,7 @@ class UserSerializer(serializers.ModelSerializer):
training = UserTrainingSerializer(many=True)
member = MemberSerializer()
transactions = serializers.SerializerMethodField()
training = serializers.SerializerMethodField()
interests = InterestSerializer(many=True)
door_code = serializers.SerializerMethodField()
wifi_pass = serializers.SerializerMethodField()
@ -771,12 +792,27 @@ class UserSerializer(serializers.ModelSerializer):
def get_transactions(self, obj):
queryset = models.Transaction.objects.filter(user=obj)
queryset = queryset.select_related('user', 'user__member')
queryset = queryset.exclude(category='Memberships:Fake Months')
queryset = queryset.order_by('-id', '-date')
serializer = TransactionSerializer(data=queryset, many=True)
serializer.is_valid()
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):
if not obj.member.paused_date and obj.cards.count():
return secrets.DOOR_CODE

View File

@ -19,11 +19,20 @@ class LoggingThrottle(throttling.BaseThrottle):
if path.startswith('/lockout/'):
return True
elif path == '/stats/sign/':
pass
pass # log this one
elif path.startswith('/stats/'):
return True
elif path == '/sessions/' and user == None:
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 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:
return 'Prepaid'
elif difference <= -3:
return 'Former Member'
return 'Expired Member'
elif today - timedelta(days=29) >= expire_date:
return 'Overdue'
elif today < expire_date:
@ -104,11 +104,26 @@ def tally_membership_months(member, fake_date=None):
status = calc_member_status(expire_date, fake_date)
if member.expire_date != expire_date or member.status != status:
previous_status = member.status
member.expire_date = expire_date
member.status = status
if status == 'Former Member':
member.paused_date = expire_date
if status == 'Expired Member':
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()
logging.debug('Tallied %s membership months: updated.', member)
@ -466,7 +481,10 @@ def gen_member_forms(member):
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
if hasattr(exc, 'detail'):
logging.warning('Response: %s', json.dumps(exc.detail))
else:
logging.warning('Response: %s', exc)
return response
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),
message=email_text,
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:
time.sleep(0.5) # simulate slowly sending emails when logging to console
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:
num_months = 3
deal_str = '3 for 2, '
elif num_months == 11: # handle pre-Spaceport yearly subs
num_months = 12
deal_str = '12 for 11 (legacy), '
else:
deal_str = ''
@ -247,7 +250,7 @@ def check_training(data, training_id, amount):
if training.attendance_status == 'Waiting for payment':
training.attendance_status = 'Confirmed'
training.paid_date = datetime.date.today()
training.paid_date = utils.today_alberta_tz()
training.save()
logger.info('IPN - Amount valid for training cost, id: ' + str(training.id))

View File

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

View File

@ -23,8 +23,7 @@ import icalendar
import datetime, time
import io
import csv
import requests
import xmltodict
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap, utils_email
from .permissions import (
@ -137,7 +136,10 @@ class SearchViewSet(Base, Retrieve):
pinball_score=Max('user__scores__score'),
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
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':
queryset = []
@ -187,20 +189,42 @@ class MemberViewSet(Base, Retrieve, Update):
if not is_admin_director(self.request.user):
raise exceptions.PermissionDenied()
member = self.get_object()
member.status = 'Former Member'
member.status = 'Paused Member'
member.paused_date = utils.today_alberta_tz()
member.save()
msg = 'Member has been paused: {} {}'.format(member.preferred_name, member.last_name)
utils.alert_tanner(msg)
logger.info(msg)
return Response(200)
@action(detail=True, methods=['post'])
def unpause(self, request, pk=None):
if not is_admin_director(self.request.user):
raise exceptions.PermissionDenied()
today = utils.today_alberta_tz()
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
if not member.monthly_fees:
member.monthly_fees = 55
member.save()
utils.tally_membership_months(member)
utils.gen_member_forms(member)
@ -296,7 +320,7 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
course=session.course,
satisfied_by__isnull=True,
user__member__paused_date__isnull=True
)
)[:20]
for num, interest in enumerate(interests):
msg = 'Sending email {} / {}...'.format(num+1, len(interests))
@ -309,7 +333,9 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
logger.exception(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)
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'])
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)
if training1.exists():
@ -471,8 +503,11 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
def get_queryset(self):
queryset = models.Transaction.objects
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':
if month:
try:
dt = datetime.datetime.strptime(month, '%Y-%m')
except ValueError:
@ -480,10 +515,15 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
queryset = queryset.filter(date__year=dt.year)
queryset = queryset.filter(date__month=dt.month)
queryset = queryset.exclude(category='Memberships:Fake Months')
return queryset.order_by('-date', '-id')
elif self.action == 'list':
else:
queryset = queryset.exclude(report_type__isnull=True)
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')
else:
return queryset.all()
@ -516,16 +556,30 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
raise exceptions.PermissionDenied()
return super().list(request)
@action(detail=True, methods=['post'])
def report(self, request, pk=None):
report_memo = request.data.get('report_memo', '').strip()
if not report_memo:
raise exceptions.ValidationError(dict(report_memo='This field may not be blank.'))
transaction = self.get_object()
transaction.report_type = 'User Flagged'
transaction.report_memo = report_memo
transaction.save()
return Response(200)
@action(detail=False, methods=['get'])
def summary(self, request):
txs = models.Transaction.objects
month = self.request.query_params.get('month', '')
try:
dt = datetime.datetime.strptime(month, '%Y-%m')
except ValueError:
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
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):
@ -555,6 +609,7 @@ class DoorViewSet(viewsets.ViewSet, List):
for card in cards:
member = card.user.member
if member.paused_date: continue
if not member.vetted_date: continue
if not member.is_allowed_entry: continue
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')
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()
return Response(200)
@ -756,7 +818,8 @@ class StatsViewSet(viewsets.ViewSet, List):
if should_count:
start_new_use = not last_use or last_use.finished_at or last_use.username != username
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)
utils.alert_tanner(msg)
logger.error(msg)
@ -891,6 +954,23 @@ class StatsViewSet(viewsets.ViewSet, List):
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):
pagination_class = None
@ -1071,6 +1151,111 @@ class InterestViewSet(Base, Retrieve, Create):
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])
def send_to_member(self, request):
try:
@ -1099,6 +1284,7 @@ class ProtocoinViewSet(Base):
except ValueError:
raise exceptions.ValidationError(dict(amount='Invalid number.'))
# also prevents negative spending
if amount < 1.00:
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 = 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.'))
if source_user_balance < amount:
@ -1175,6 +1361,38 @@ class ProtocoinViewSet(Base):
)
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'])
def card_vend_request(self, request, pk=None):
try:
@ -1186,6 +1404,8 @@ class ProtocoinViewSet(Base):
source_card = get_object_or_404(models.Card, card_number=pk)
source_user = source_card.user
machine = request.data.get('machine', 'unknown')
try:
number = request.data['number']
except KeyError:
@ -1205,6 +1425,7 @@ class ProtocoinViewSet(Base):
except ValueError:
raise exceptions.ValidationError(dict(amount='Invalid number.'))
# also prevents negative spending
if amount < 0.25:
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 = 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.'))
if source_user_balance < amount:
@ -1220,8 +1441,9 @@ class ProtocoinViewSet(Base):
source_delta = -amount
memo = 'Protocoin - Purchase spent ₱ {} on vending machine item #{}'.format(
memo = 'Protocoin - Purchase spent ₱ {} on {} vending machine item #{}'.format(
amount,
machine,
number,
)
@ -1254,6 +1476,128 @@ class ProtocoinViewSet(Base):
)
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):
@action(detail=False, methods=['post'])
@ -1303,6 +1647,143 @@ class PinballViewSet(Base):
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):
permission_classes = [AllowMetadata | IsAdmin]

View File

@ -217,7 +217,7 @@ if DEBUG:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 300,
'PAGE_SIZE': 500,
'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES,
'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES,
'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'],
@ -250,6 +250,11 @@ LOGGING = {
},
},
'loggers': {
#'django.db.backends': {
# 'handlers': ['console'],
# 'level': 'DEBUG',
# 'propagate': False,
# },
'gunicorn': {
'handlers': ['console'],
'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'pinball', views.PinballViewSet, basename='pinball')
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'training', views.TrainingViewSet, basename='training')
router.register(r'interest', views.InterestViewSet, basename='interest')

View File

@ -73,4 +73,5 @@ typing-extensions==4.0.1
urllib3==1.25.11
wcwidth==0.2.5
webencodings==0.5.1
xmltodict==0.13.0
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')
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.writeheader()
@ -17,6 +17,7 @@ with open('output.csv', 'w', newline='') as csvfile:
writer.writerow(dict(
date=s.datetime.date(),
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;
return (
<div>
<div className='adminvetting'>
{!error ?
vetting ?
<>
<Table collapsing basic='very'>
<Table compact collapsing unstackable basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Start Date</Table.HeaderCell>
<Table.HeaderCell>Status / NMO</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<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.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>
<Icon name='circle' color={statusColor[x.status]} />
{x.status || 'Unknown'}
{x.orientation_date ? '✅' : '❌'}
</Table.Cell>
<Table.Cell>{x.current_start_date}</Table.Cell>
<Table.Cell><AdminVet {...props} member={x} refreshVetting={refreshVetting} /></Table.Cell>
</Table.Row>
)}
@ -344,6 +340,7 @@ export function Admin(props) {
<Header size='medium'>Ready to Vet</Header>
<p>Members who are Current or Due, and past their probationary period.</p>
<p>Sorted by last name.</p>
<AdminVetting {...props} />

View File

@ -124,7 +124,7 @@ let prevAutoscan = '';
export function AdminMemberCards(props) {
const { token, result, refreshResult } = props;
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 [input, setInput] = useState({ active_status: 'card_active' });
const [error, setError] = useState(false);
@ -134,7 +134,7 @@ export function AdminMemberCards(props) {
const { id } = useParams();
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);
}, [result.member]);
@ -298,7 +298,7 @@ export function AdminMemberCards(props) {
<Dimmer active={dimmed}>
<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>
<Button size='tiny' onClick={() => setDimmed(false)}>Close</Button>
@ -363,18 +363,77 @@ export function AdminMemberPause(props) {
<div>
<Header size='medium'>Pause / Unpause Membership</Header>
<p>Pause members who are inactive, former, or on vacation.</p>
<p>
{result.member.paused_date ?
result.member.vetted_date && moment().diff(moment(result.member.paused_date), 'days') > 370 ?
<>
<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>
<Form.Checkbox
name='told_subscriptions'
name='told1'
value={told1}
label='Told member to stop any PayPal subscriptions'
required
@ -383,7 +442,7 @@ export function AdminMemberPause(props) {
</p>
<p>
<Form.Checkbox
name='told_shelves'
name='told2'
value={told2}
label='Told member to clear any shelves'
required

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
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 'react-datetime/css/react-datetime.css';
import moment from 'moment';
@ -42,28 +42,25 @@ export function AdminReportedTransactions(props) {
};
let transactionsCache = false;
let excludePayPalCache = false;
let summaryCache = false;
export function AdminHistoricalTransactions(props) {
const { token } = props;
const [input, setInput] = useState({ month: moment() });
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 [error, setError] = useState(false);
const handleDatetime = (v) => setInput({ ...input, month: v });
const handleExcludePayPal = (e, v) => {
setExcludePayPal(v.checked);
excludePayPalCache = v.checked;
};
const handleSubmit = (e) => {
const makeRequest = () => {
if (loading) return;
setLoading(true);
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 => {
setLoading(false);
setError(false);
@ -75,8 +72,37 @@ export function AdminHistoricalTransactions(props) {
console.log(err);
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 (
<div>
<Form onSubmit={handleSubmit}>
@ -96,21 +122,59 @@ export function AdminHistoricalTransactions(props) {
</Form.Button>
</Form.Group>
</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 ?
transactions && <div>
<p>Found {transactions.length} transactions.</p>
{!!transactions.length &&
<Header size='small'>{moment(transactions[0].date, 'YYYY-MM-DD').format('MMMM YYYY')} Transactions</Header>
}
<Checkbox
className='filter-option'
label='Exclude PayPal'
onChange={handleExcludePayPal}
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>
:
<p>Error loading transactions.</p>

View File

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

View File

@ -20,8 +20,8 @@ export function Cards(props) {
{user.member.card_photo ?
<p>
<a href={staticUrl + '/' + user.member.card_photo} target='_blank'>
Click here
</a> to view your card image.
View your card image.
</a>
</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} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip />
<Tooltip labelFormatter={t => moment(t).format('YYYY-MM-DD ddd')} />
<Legend />
<Bar

View File

@ -7,6 +7,7 @@ import { apiUrl, isAdmin, getInstructor, BasicTable, requester, useIsMobile } fr
import { NotFound } from './Misc.js';
import { InstructorClassDetail, InstructorClassAttendance } from './InstructorClasses.js';
import { PayPalPayNow } from './PayPal.js';
import { PayWithProtocoin } from './Paymaster.js';
import { tags } from './Courses.js';
function ClassTable(props) {
@ -298,6 +299,8 @@ export function ClassFeed(props) {
:
<p>Loading...</p>
}
<p style={{ marginBottom: '30rem' }}/>
</Container>
);
};
@ -691,6 +694,20 @@ export function ClassDetail(props) {
name={clazz.course_data.name}
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>

View File

@ -235,7 +235,7 @@ export function CourseDetail(props) {
<Table.Body>
{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.Cell>
<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='/display/lcars1'>LCARS1 Display</Link></p>
<p><Link to='/display/lcars2'>LCARS2 Display</Link></p>
</Container>
);

View File

@ -34,7 +34,17 @@ export function LCARS1Display(props) {
</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'>
<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) {
const { token, name } = props;
const title = deviceNames[name].title;
@ -67,21 +114,128 @@ export function DisplayUsage(props) {
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 (
<>
<Header size='large'>Trotec Usage</Header>
{showUsage ?
<TrotecUsage usage={usage} />
:
<>
<Header size='medium'>Trotec Usage</Header>
<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"
rel="noopener noreferrer"
>
Click here
View the source code and license on GitHub.
</a>{' '}
to view the source code and license.
</p>
<p>
@ -97,7 +97,7 @@ export const Footer = () => {
</a>
</p>
<p>© 2020 Calgary Protospace Ltd.</p>
<p>© 2020-{new Date().getFullYear()} Calgary Protospace Ltd.</p>
</Container>
</div>
);

View File

@ -22,6 +22,11 @@ function MemberInfo(props) {
return (
<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.Column width={5}>
<Image
@ -65,8 +70,8 @@ function MemberInfo(props) {
<Message.Header>Welcome, new member!</Message.Header>
<p>
<a href={staticUrl + '/' + member.member_forms} target='_blank'>
Click here
</a> to view your application forms.
View your application forms.
</a>
</p>
</Message>}
@ -89,8 +94,6 @@ function MemberInfo(props) {
<QRCode value={siteUrl + 'subscribe?monthly_fees=' + user.member.monthly_fees + '&id=' + user.member.id} />
</React.Fragment>}
<Header size='medium'>Latest Training</Header>
{unpaidTraining.map(x =>
<Message warning>
<Message.Header>Please pay your course fee!</Message.Header>
@ -98,6 +101,12 @@ function MemberInfo(props) {
</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>
<Table.Body>
{lastTrain.length ?
@ -110,7 +119,7 @@ function MemberInfo(props) {
</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 &&
<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 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;
@ -261,8 +276,8 @@ export function Home(props) {
{user?.member?.set_details !== false &&
<Segment>
<Header size='medium'>Quick Links</Header>
<p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p>
<p><a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>Protospace Wiki</a> <Link to='/auth/wiki'>[register]</Link></p>
<p><a href='https://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></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>
{!!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>}
@ -348,7 +363,33 @@ export function Home(props) {
} trigger={<a>[more]</a>} />
</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>Hosting status: {closedStat()}</p>}
</div>
<SignForm token={token} />

View File

@ -230,7 +230,7 @@ export function Members(props) {
</Item.Header>
{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>
</>
:

View File

@ -6,6 +6,46 @@ import { PayPalPayNow, PayPalSubscribe } from './PayPal.js';
import { MembersDropdown } from './Members.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) {
const { token, user, refreshUser } = props;
const member = user.member;
@ -76,10 +116,10 @@ export function Paymaster(props) {
const { token, user, refreshUser } = props;
const [pop, setPop] = useState('20.00');
const [locker, setLocker] = useState('5.00');
const [consumables, setConsumables] = useState('20.00');
const [consumables, setConsumables] = useState('');
const [buyProtocoin, setBuyProtocoin] = useState('10.00');
const [consumablesMemo, setConsumablesMemo] = useState('');
const [donate, setDonate] = useState('20.00');
const [donate, setDonate] = useState('');
const [memo, setMemo] = useState('');
const monthly_fees = user.member.monthly_fees || 55;
@ -156,12 +196,12 @@ export function Paymaster(props) {
</Grid.Column>
</Grid>
<Grid stackable columns={2}>
<Grid.Column>
<Header size='medium'>Consumables</Header>
<p>Pay for materials you use (ie. welding gas, 3D printing, blades, etc).</p>
<p>Pay for materials you use (ie. welding gas, 3D printing, etc).</p>
<Grid stackable padded columns={1}>
<Grid.Column>
Custom amount:
<div className='pay-custom'>
@ -188,13 +228,21 @@ export function Paymaster(props) {
name='Protospace Consumables'
custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })}
/>
</Grid.Column>
</Grid>
<p/>
<PayWithProtocoin
token={token} user={user} refreshUser={refreshUser}
amount={consumables}
onSuccess={() => setConsumables('')}
custom={{ category: 'Consumables', memo: consumablesMemo }}
/>
</Grid.Column>
<Grid.Column>
<Header size='medium'>Donate</Header>
<Grid stackable padded columns={1}>
<Grid.Column>
<p>Donation of any amount to Protospace.</p>
Custom amount:
<div className='pay-custom'>
@ -221,6 +269,15 @@ export function Paymaster(props) {
name='Protospace Donation'
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>

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) {
const { transactions, noMember, noCategory } = props;
@ -474,7 +414,11 @@ export function TransactionDetail(props) {
</Segment>
:
<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>
}
</Grid.Column>

View File

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

View File

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