diff --git a/apiserver/apiserver/api/admin.py b/apiserver/apiserver/api/admin.py index 0b3adab..1d6f3b7 100644 --- a/apiserver/apiserver/api/admin.py +++ b/apiserver/apiserver/api/admin.py @@ -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: diff --git a/apiserver/apiserver/api/emails/overdue.html b/apiserver/apiserver/api/emails/overdue.html new file mode 100644 index 0000000..97e6bff --- /dev/null +++ b/apiserver/apiserver/api/emails/overdue.html @@ -0,0 +1,26 @@ + + + + + + + +
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 card and account 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
+ + diff --git a/apiserver/apiserver/api/emails/overdue.txt b/apiserver/apiserver/api/emails/overdue.txt new file mode 100644 index 0000000..cd651ee --- /dev/null +++ b/apiserver/apiserver/api/emails/overdue.txt @@ -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 diff --git a/apiserver/apiserver/api/management/commands/run_hourly.py b/apiserver/apiserver/api/management/commands/run_hourly.py index b4a6e2d..4778375 100644 --- a/apiserver/apiserver/api/management/commands/run_hourly.py +++ b/apiserver/apiserver/api/management/commands/run_hourly.py @@ -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] )) diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py index bd43ce0..e57d534 100644 --- a/apiserver/apiserver/api/models.py +++ b/apiserver/apiserver/api/models.py @@ -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 diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index db6cd2f..094f5ed 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -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 diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py index e2aeb57..042c5e7 100644 --- a/apiserver/apiserver/api/throttles.py +++ b/apiserver/apiserver/api/throttles.py @@ -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: diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py index 56c3e1c..9b3442e 100644 --- a/apiserver/apiserver/api/utils.py +++ b/apiserver/apiserver/api/utils.py @@ -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): diff --git a/apiserver/apiserver/api/utils_email.py b/apiserver/apiserver/api/utils_email.py index 8254f1c..d713da9 100644 --- a/apiserver/apiserver/api/utils_email.py +++ b/apiserver/apiserver/api/utils_email.py @@ -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) diff --git a/apiserver/apiserver/api/utils_paypal.py b/apiserver/apiserver/api/utils_paypal.py index ddd9cf1..b5078cf 100644 --- a/apiserver/apiserver/api/utils_paypal.py +++ b/apiserver/apiserver/api/utils_paypal.py @@ -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)) diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py index 3adf999..832c162 100644 --- a/apiserver/apiserver/api/utils_stats.py +++ b/apiserver/apiserver/api/utils_stats.py @@ -28,6 +28,9 @@ DEFAULTS = { 'sign': '', 'link': '', 'autoscan': '', + 'last_scan': {}, + 'closing': {}, + 'printer3d': {}, } if secrets.MUMBLE: diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index e46c133..c80688a 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -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] diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py index 00bddbb..6c9dd18 100644 --- a/apiserver/apiserver/settings.py +++ b/apiserver/apiserver/settings.py @@ -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', diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index 2ee92ae..27d1025 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -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') diff --git a/apiserver/requirements.txt b/apiserver/requirements.txt index 908e498..acdb27f 100644 --- a/apiserver/requirements.txt +++ b/apiserver/requirements.txt @@ -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 diff --git a/apiserver/delete_course_merge_into.py b/apiserver/scripts/delete_course_merge_into.py similarity index 100% rename from apiserver/delete_course_merge_into.py rename to apiserver/scripts/delete_course_merge_into.py diff --git a/apiserver/scripts/distinguish_paused_expired.py b/apiserver/scripts/distinguish_paused_expired.py new file mode 100755 index 0000000..f72370f --- /dev/null +++ b/apiserver/scripts/distinguish_paused_expired.py @@ -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) diff --git a/apiserver/scripts/export_class_report.py b/apiserver/scripts/export_class_report.py index 834c7a6..4b1459c 100755 --- a/apiserver/scripts/export_class_report.py +++ b/apiserver/scripts/export_class_report.py @@ -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(), )) diff --git a/webclient/public/toast.png b/webclient/public/toast.png new file mode 100644 index 0000000..0f59c62 Binary files /dev/null and b/webclient/public/toast.png differ diff --git a/webclient/src/Admin.js b/webclient/src/Admin.js index efe5750..c6841a9 100644 --- a/webclient/src/Admin.js +++ b/webclient/src/Admin.js @@ -69,31 +69,27 @@ export function AdminVetting(props) { const displayAll = (vetting && vetting.length <= 5) || showAll; return ( -
+
{!error ? vetting ? <> - +
Name - - Status - Start Date + Status / NMO - {(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 => {x.preferred_name} {x.last_name} - Email - {x.status || 'Unknown'} + {x.orientation_date ? '✅' : '❌'} - {x.current_start_date} )} @@ -344,6 +340,7 @@ export function Admin(props) {
Ready to Vet

Members who are Current or Due, and past their probationary period.

+

Sorted by last name.

diff --git a/webclient/src/AdminMembers.js b/webclient/src/AdminMembers.js index 8c6addf..267dbed 100644 --- a/webclient/src/AdminMembers.js +++ b/webclient/src/AdminMembers.js @@ -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) {

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

@@ -363,18 +363,77 @@ export function AdminMemberPause(props) {

Pause / Unpause Membership
-

Pause members who are inactive, former, or on vacation.

-

{result.member.paused_date ? - + result.member.vetted_date && moment().diff(moment(result.member.paused_date), 'days') > 370 ? + <> +

+ {result.member.preferred_name} has been away for more than a year and will need to be re-vetted according to our + policy. +

+

+ setTold1(v.checked)} + /> +

+

+ setTold2(v.checked)} + /> +

+ + + + : + result.member.status == 'Expired Member' ? + <> +

+ {result.member.preferred_name} has expired due to lapse of payment. +

+

+ setTold1(v.checked)} + /> +

+

+ setTold2(v.checked)} + /> +

+ + + + : + : <> +

Pause members who are inactive, former, or on vacation.

+

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 (

@@ -96,21 +122,59 @@ export function AdminHistoricalTransactions(props) { + {transactions &&

Found {transactions.length} transactions.

} + + {!error ? + summary &&
+
Summary
+ +
+ + + Category + Dollar + Protocoin + + + + + {summary.map(x => + + {x.category} + {'$ ' + x.dollar.toFixed(2)} + {'₱ ' + x.protocoin.toFixed(2)} + + )} + +
+
+ : +

Error loading summary.

+ } + +

{!error ? transactions &&

-

Found {transactions.length} transactions.

{!!transactions.length &&
{moment(transactions[0].date, 'YYYY-MM-DD').format('MMMM YYYY')} Transactions
} - !excludePayPal || x.account_type !== 'PayPal')} /> + + +
:

Error loading transactions.

diff --git a/webclient/src/App.js b/webclient/src/App.js index e6d11de..1d6d8ac 100644 --- a/webclient/src/App.js +++ b/webclient/src/App.js @@ -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() { + + + +
diff --git a/webclient/src/Cards.js b/webclient/src/Cards.js index d424ddd..89a77d7 100644 --- a/webclient/src/Cards.js +++ b/webclient/src/Cards.js @@ -20,8 +20,8 @@ export function Cards(props) { {user.member.card_photo ?

- Click here - to view your card image. + View your card image. +

:

Upload a photo to generate a card image.

diff --git a/webclient/src/Charts.js b/webclient/src/Charts.js index 3096699..980239a 100644 --- a/webclient/src/Charts.js +++ b/webclient/src/Charts.js @@ -310,7 +310,7 @@ export function Charts(props) { - + moment(t).format('YYYY-MM-DD ddd')} /> Loading...

} + +

); }; @@ -691,6 +694,20 @@ export function ClassDetail(props) { name={clazz.course_data.name} custom={JSON.stringify({ training: userTraining.id })} /> + +

+ +

Current balance: ₱ {user.member.protocoin.toFixed(2)}

+ + { + refreshUser(); + refreshClass(); + }} + custom={{ category: 'OnAcct', training: userTraining.id }} + />
}
diff --git a/webclient/src/Courses.js b/webclient/src/Courses.js index 364d007..c696875 100644 --- a/webclient/src/Courses.js +++ b/webclient/src/Courses.js @@ -235,7 +235,7 @@ export function CourseDetail(props) { {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 => diff --git a/webclient/src/Debug.js b/webclient/src/Debug.js index ce62fc4..350719f 100644 --- a/webclient/src/Debug.js +++ b/webclient/src/Debug.js @@ -26,6 +26,10 @@ export function Debug(props) {

Trotec Usage

+

LCARS1 Display

+ +

LCARS2 Display

+ ); diff --git a/webclient/src/Display.js b/webclient/src/Display.js index 471cbc4..a4388f9 100644 --- a/webclient/src/Display.js +++ b/webclient/src/Display.js @@ -34,7 +34,17 @@ export function LCARS1Display(props) {

} -
+
+ +
+ +
+ +
+ +
+ +
@@ -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 ( + +
+ + {!fullElement && +

+ +

+ } + +
+ +
+ +
+ +
+
+
+ ); +}; + 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 ( <> +
Trotec Usage
+ {showUsage ? : - <> -
Trotec Usage
- -

- Waiting for job -

- +

+ Waiting for job +

} ); }; + +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 ( + <> +
Pinball High Scores
+ + {scores && scores.slice(0, 5).map((x, i) => +
+
#{i+1} — {x.name}. {i === 0 ? '👑' : ''}
+

{x.score.toLocaleString()}

+
+ )} + + + ); +}; + +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 ( + <> +
Monthly High Scores
+ + {scores && scores.slice(0, 5).map((x, i) => +
+
#{i+1} — {x.name}. {i === 0 ? '🧙' : ''}
+

{x.score.toLocaleString()}

+
+ )} + + + ); +}; + +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 ( + <> +
Most Host
+ + {scores && scores.slice(0, 5).map((x, i) => +
+
#{i+1} — {x.name}. {i === 0 ? : ''}
+

{x.hours.toFixed(2)} hours

+
+ )} + + + ); +}; diff --git a/webclient/src/Footer.js b/webclient/src/Footer.js index 672b248..1668159 100644 --- a/webclient/src/Footer.js +++ b/webclient/src/Footer.js @@ -49,9 +49,9 @@ export const Footer = () => { target="_blank" rel="noopener noreferrer" > - Click here + View the source code and license on GitHub. {' '} - to view the source code and license. +

@@ -97,7 +97,7 @@ export const Footer = () => {

-

© 2020 Calgary Protospace Ltd.

+

© 2020-{new Date().getFullYear()} Calgary Protospace Ltd.

); diff --git a/webclient/src/Home.js b/webclient/src/Home.js index 6e2aa15..77ceeb9 100644 --- a/webclient/src/Home.js +++ b/webclient/src/Home.js @@ -22,6 +22,11 @@ function MemberInfo(props) { return (
+ {member.protocoin < 0 && + Your Protocoin balance is negative! +

Visit the Paymaster page or pay a Director to buy Protocoin.

+
} + Welcome, new member!

- Click here - to view your application forms. + View your application forms. +

} @@ -89,8 +94,6 @@ function MemberInfo(props) { Latest Training - {unpaidTraining.map(x => Please pay your course fee! @@ -98,6 +101,12 @@ function MemberInfo(props) { )} +
Latest Training
+ + {!member.orientation_date &&

+ ⚠️ You need to attend a New Member Orientation to use any tool larger than a screwdriver. +

} + {lastTrain.length ? @@ -110,7 +119,7 @@ function MemberInfo(props) { ) : - None, please sign up for an Orientation + None } {user.training.length > 3 && @@ -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 &&
Quick Links
-

Main Website

-

Protospace Wiki[register]

+

Main Website

+

Protospace Wiki[register]

Forum (Spacebar)[register]

{!!user &&

Google Drive

} {!!user && isAdmin(user) &&

Property Management Portal

} @@ -348,7 +363,33 @@ export function Home(props) { } trigger={[more]} />

+

+ Media computer: {getTrackStat('PROTOGRAPH1')} +

+ Last use:
+ {getTrackLast('PROTOGRAPH1')}
+ {getTrackAgo('PROTOGRAPH1')}
+ by {getTrackName('PROTOGRAPH1')} +

+ +

+ Last print:
+ {getTrackLast('LASTLARGEPRINT')}
+ {getTrackAgo('LASTLARGEPRINT')}
+ by {getTrackName('LASTLARGEPRINT')} +

+ + } trigger={[more]} /> +

+ +

ORD2 printer: {printer3dStat('ord2')}

+ +

ORD3 printer: {printer3dStat('ord3')}

+ {user &&

Alarm status: {alarmStat()}{doorOpenStat()}

} + + {user &&

Hosting status: {closedStat()}

}
diff --git a/webclient/src/Members.js b/webclient/src/Members.js index 5957fb6..950df80 100644 --- a/webclient/src/Members.js +++ b/webclient/src/Members.js @@ -230,7 +230,7 @@ export function Members(props) { {sort === 'pinball_score' ? <> - Score: {x.member.pinball_score || 'Unknown'} + Score: {x.member.pinball_score.toLocaleString() || 'Unknown'} Rank: {i === 0 ? 'Pinball Wizard' : 'Not the Pinball Wizard'} : diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js index 63cd02e..2b9dbbc 100644 --- a/webclient/src/Paymaster.js +++ b/webclient/src/Paymaster.js @@ -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 ( +
+ + Pay with Protocoin + + {success &&
Success!
} +
+ ); +}; + 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) { -
Consumables
- -

Pay for materials you use (ie. welding gas, 3D printing, blades, etc).

- - + +
Consumables
+ +

Pay for materials you use (ie. welding gas, 3D printing, etc).

+ Custom amount:
@@ -188,13 +228,21 @@ export function Paymaster(props) { name='Protospace Consumables' custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })} /> + +

+ + setConsumables('')} + custom={{ category: 'Consumables', memo: consumablesMemo }} + /> - - -

Donate
- - +
Donate
+ +

Donation of any amount to Protospace.

+ Custom amount:
@@ -221,6 +269,15 @@ export function Paymaster(props) { name='Protospace Donation' custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })} /> + +

+ + setDonate('')} + custom={{ category: 'Donation', memo: memo }} + /> diff --git a/webclient/src/Transactions.js b/webclient/src/Transactions.js index 1c1226b..be03b14 100644 --- a/webclient/src/Transactions.js +++ b/webclient/src/Transactions.js @@ -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 ( -

-
Report Transaction
- -

If this transaction was made in error or there is anything incorrect about it, please report it using this form.

-

A staff member will review the report as soon as possible.

-

Follow up with directors@protospace.ca.

- -
- - - - Submit Report - - {success &&
Success!
} - -
- ); -}; export function TransactionList(props) { const { transactions, noMember, noCategory } = props; @@ -474,7 +414,11 @@ export function TransactionDetail(props) { : - +
Report Transaction
+ +

If there's anything wrong with this transaction or it was made in error please email the Protospace Directors:

+

directors@protospace.ca

+

Please include a link to this transaction and any relevant details.

} diff --git a/webclient/src/light.css b/webclient/src/light.css index f48b1d0..6a318f5 100644 --- a/webclient/src/light.css +++ b/webclient/src/light.css @@ -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; diff --git a/webclient/src/utils.js b/webclient/src/utils.js index 36bf4e1..3fc54c0 100644 --- a/webclient/src/utils.js +++ b/webclient/src/utils.js @@ -30,6 +30,8 @@ export const statusColor = { 'Due': 'yellow', 'Overdue': 'red', 'Former Member': 'black', + 'Paused Member': 'black', + 'Expired Member': 'black', }; export const BasicTable = (props) => (