Merge branch 'master' into storage_space
This commit is contained in:
commit
972b9492d8
|
@ -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:
|
||||
|
|
26
apiserver/apiserver/api/emails/overdue.html
Normal file
26
apiserver/apiserver/api/emails/overdue.html
Normal 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>
|
19
apiserver/apiserver/api/emails/overdue.txt
Normal file
19
apiserver/apiserver/api/emails/overdue.txt
Normal 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
|
|
@ -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]
|
||||
))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
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
|
||||
|
||||
def log_transaction(tx):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -28,6 +28,9 @@ DEFAULTS = {
|
|||
'sign': '',
|
||||
'link': '',
|
||||
'autoscan': '',
|
||||
'last_scan': {},
|
||||
'closing': {},
|
||||
'printer3d': {},
|
||||
}
|
||||
|
||||
if secrets.MUMBLE:
|
||||
|
|
|
@ -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,19 +503,27 @@ 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:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(month, '%Y-%m')
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
|
||||
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':
|
||||
queryset = queryset.exclude(report_type__isnull=True)
|
||||
queryset = queryset.exclude(report_type='')
|
||||
if self.action == 'list':
|
||||
if month:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(month, '%Y-%m')
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
|
||||
queryset = queryset.filter(date__year=dt.year)
|
||||
queryset = queryset.filter(date__month=dt.month)
|
||||
queryset = queryset.exclude(category='Memberships:Fake Months')
|
||||
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]
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
32
apiserver/scripts/distinguish_paused_expired.py
Executable file
32
apiserver/scripts/distinguish_paused_expired.py
Executable 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)
|
|
@ -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
BIN
webclient/public/toast.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.1 KiB |
|
@ -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} />
|
||||
|
||||
|
||||
|
|
|
@ -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 ?
|
||||
<Button onClick={handleUnpause} loading={loading}>
|
||||
Unpause
|
||||
</Button>
|
||||
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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: ₱ {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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
:
|
||||
|
|
|
@ -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>
|
||||
|
||||
<Header size='medium'>Consumables</Header>
|
||||
|
||||
<p>Pay for materials you use (ie. welding gas, 3D printing, blades, etc).</p>
|
||||
|
||||
<Grid stackable padded columns={1}>
|
||||
<Grid stackable columns={2}>
|
||||
<Grid.Column>
|
||||
<Header size='medium'>Consumables</Header>
|
||||
|
||||
<p>Pay for materials you use (ie. welding gas, 3D printing, etc).</p>
|
||||
|
||||
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 })}
|
||||
/>
|
||||
|
||||
<p/>
|
||||
|
||||
<PayWithProtocoin
|
||||
token={token} user={user} refreshUser={refreshUser}
|
||||
amount={consumables}
|
||||
onSuccess={() => setConsumables('')}
|
||||
custom={{ category: 'Consumables', memo: consumablesMemo }}
|
||||
/>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
|
||||
<Header size='medium'>Donate</Header>
|
||||
|
||||
<Grid stackable padded columns={1}>
|
||||
<Grid.Column>
|
||||
<Header size='medium'>Donate</Header>
|
||||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -30,6 +30,8 @@ export const statusColor = {
|
|||
'Due': 'yellow',
|
||||
'Overdue': 'red',
|
||||
'Former Member': 'black',
|
||||
'Paused Member': 'black',
|
||||
'Expired Member': 'black',
|
||||
};
|
||||
|
||||
export const BasicTable = (props) => (
|
||||
|
|
Loading…
Reference in New Issue
Block a user