From 01f8039379962a0cf145345c44b2a6cb5aa126b7 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sun, 27 Nov 2022 16:30:21 +0000
Subject: [PATCH 01/76] Make member's meeting suggester skip December
---
apiserver/apiserver/api/serializers.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index 96ffcd7..2c61918 100644
--- a/apiserver/apiserver/api/serializers.py
+++ b/apiserver/apiserver/api/serializers.py
@@ -616,9 +616,10 @@ 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
@@ -639,9 +640,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)
From 4b1da0fd926aaa033b4ba726801ec73f48c6f147 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sun, 27 Nov 2022 22:37:56 +0000
Subject: [PATCH 02/76] Prevent registering the instructor for classes
---
apiserver/apiserver/api/views.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index df2e108..8ed8b4b 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -430,6 +430,11 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
member = get_object_or_404(models.Member, id=data['member_id'])
user = member.user
+ if 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():
raise exceptions.ValidationError(dict(non_field_errors='Already registered.'))
From 1de77062166cfcb91c50481777fb508dd726d61d Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 28 Nov 2022 14:42:48 +0000
Subject: [PATCH 03/76] Ensure previous classes aren't modified
---
apiserver/apiserver/api/serializers.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index 2c61918..8a1d4f5 100644
--- a/apiserver/apiserver/api/serializers.py
+++ b/apiserver/apiserver/api/serializers.py
@@ -577,10 +577,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.'))
From db9bd91f9715209a9e18dd937a27ec47beb40fdd Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 16 Dec 2022 19:18:45 +0000
Subject: [PATCH 04/76] Distinguish between Paused and Expired members
---
apiserver/apiserver/api/utils.py | 9 ++++---
apiserver/apiserver/api/views.py | 6 ++++-
apiserver/distinguish_paused_expired.py | 32 +++++++++++++++++++++++++
webclient/src/utils.js | 2 ++
4 files changed, 45 insertions(+), 4 deletions(-)
create mode 100755 apiserver/distinguish_paused_expired.py
diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py
index 56c3e1c..f488d3e 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:
@@ -107,8 +107,11 @@ def tally_membership_months(member, fake_date=None):
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)
member.save()
logging.debug('Tallied %s membership months: updated.', member)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 8ed8b4b..9ff500f 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -183,9 +183,13 @@ 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'])
diff --git a/apiserver/distinguish_paused_expired.py b/apiserver/distinguish_paused_expired.py
new file mode 100755
index 0000000..f72370f
--- /dev/null
+++ b/apiserver/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/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) => (
From a9f20e7bdf3a530e7490cb387023d45e292f7ee6 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 16 Dec 2022 19:25:09 +0000
Subject: [PATCH 05/76] Move distinguish_paused_expired script
---
apiserver/{ => scripts}/distinguish_paused_expired.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename apiserver/{ => scripts}/distinguish_paused_expired.py (100%)
diff --git a/apiserver/distinguish_paused_expired.py b/apiserver/scripts/distinguish_paused_expired.py
similarity index 100%
rename from apiserver/distinguish_paused_expired.py
rename to apiserver/scripts/distinguish_paused_expired.py
From fbc9eedef9b2d328b3d6390e4b5a8df993398458 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 21 Dec 2022 06:00:42 +0000
Subject: [PATCH 06/76] Add day of week to card scans chart
---
webclient/src/Charts.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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')} />
Date: Wed, 21 Dec 2022 16:44:01 -0700
Subject: [PATCH 07/76] Fix http links
---
webclient/src/Home.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index 6e2aa15..a2576fc 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -261,8 +261,8 @@ export function Home(props) {
{user?.member?.set_details !== false &&
- Main Website
- Protospace Wiki — [register]
+ Main Website
+ Protospace Wiki — [register]
Forum (Spacebar) — [register]
{!!user && Google Drive
}
{!!user && isAdmin(user) && Property Management Portal
}
From 603646947a9b21ad3c260da2db8e71d748c4823d Mon Sep 17 00:00:00 2001
From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com>
Date: Wed, 21 Dec 2022 16:45:19 -0700
Subject: [PATCH 08/76] Remove instances of 'Click here' in UI
---
webclient/src/Cards.js | 4 ++--
webclient/src/Footer.js | 4 ++--
webclient/src/Home.js | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
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/Footer.js b/webclient/src/Footer.js
index 672b248..878b4c2 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.
+
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index a2576fc..d23558e 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -65,8 +65,8 @@ function MemberInfo(props) {
Welcome, new member!
- Click here
- to view your application forms.
+ View your application forms.
+
}
From 23938aa075c79ba4c7a39cb786726fe35d6832f7 Mon Sep 17 00:00:00 2001
From: DeflateAwning <11021263+DeflateAwning@users.noreply.github.com>
Date: Wed, 21 Dec 2022 16:47:21 -0700
Subject: [PATCH 09/76] Fix out-of-date footer copyright
---
webclient/src/Footer.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/Footer.js b/webclient/src/Footer.js
index 878b4c2..1668159 100644
--- a/webclient/src/Footer.js
+++ b/webclient/src/Footer.js
@@ -97,7 +97,7 @@ export const Footer = () => {
- © 2020 Calgary Protospace Ltd.
+ © 2020-{new Date().getFullYear()} Calgary Protospace Ltd.
);
From 066dcd6a3081ebb8b878e5a952ba9cd0daad159c Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 26 Dec 2022 19:15:38 +0000
Subject: [PATCH 10/76] Test alert when someone goes overdue
---
apiserver/apiserver/api/utils.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py
index f488d3e..0ebb888 100644
--- a/apiserver/apiserver/api/utils.py
+++ b/apiserver/apiserver/api/utils.py
@@ -104,6 +104,8 @@ 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
@@ -113,6 +115,11 @@ def tally_membership_months(member, fake_date=None):
alert_tanner(msg)
logger.info(msg)
+ if status == 'Overdue' and previous_status == 'Due':
+ msg = 'Member has become Overdue: {} {}'.format(member.preferred_name, member.last_name)
+ alert_tanner(msg)
+ logger.info(msg)
+
member.save()
logging.debug('Tallied %s membership months: updated.', member)
else:
From 672a963ea62eeb73ac551e09506aa3824632129e Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 30 Dec 2022 21:51:40 +0000
Subject: [PATCH 11/76] Integrate large format printer with Protocoin
---
apiserver/apiserver/api/throttles.py | 3 +
apiserver/apiserver/api/views.py | 125 ++++++++++++++++++++++++++-
2 files changed, 126 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index e2aeb57..5eadb37 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,6 +24,9 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
+ elif path == '/protocoin/printer_report/':
+ logging.info('%s %s | User: %s | Data: [XML]', method, path, user)
+ return True
if request.data:
if type(request.data) is not dict:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 9ff500f..ef99f5c 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 (
@@ -1258,6 +1257,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()
+
+ xml_start = len('XML_string=')
+ xml_string = request.body.decode()[xml_start:]
+ report_json = xmltodict.parse(xml_string)
+
+ jobs = report_json['xdm:Device']['xdm:Metrics']['xdm:JobHistory']['xdm:Job']
+ jobs.sort(key=lambda x: x['job:Job']['job:Processing']['pwg:DateTimeAtCreation'], reverse=True)
+
+ logging.info('Sorted %s jobs by creation date.', str(len(jobs)))
+
+ #import json
+ #print(json.dumps(jobs, indent=4))
+
+ # most recent job might be an automatic service job
+ # so iterate until we find a userIO job
+ for job in jobs:
+ previous_job = job['job:Job']
+ source = previous_job['job:Source'].get('dd:JobSource', None)
+ if source == 'userIO': break
+
+ job_uuid = previous_job.get('dd:UUID', None)
+ username = previous_job['job:Source']['job:Client'].get('dd:UserName', None)
+
+ 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)
+
+ is_completed = previous_job.get('dd:EndState', None) == 'Completed'
+ is_print = previous_job.get('dd:JobType', None) == 'print'
+
+ 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.20
+ PAPER_PROTOCOIN_PER_M = 0.25
+ PROTOCOIN_PER_PRINT = 2.0
+
+ total_cost = PROTOCOIN_PER_PRINT
+ logging.info(' Fixed cost: %s', str(PROTOCOIN_PER_PRINT))
+
+ counters = previous_job['job:Processing']['job:JobTotals']['count:Counter']
+
+ for counter in counters:
+ if counter['dd:CounterTarget'] == 'inkUsed':
+ microliters = float(counter['dd:ValueFloat'])
+ millilitres = microliters / 1000.0
+ cost = millilitres * INK_PROTOCOIN_PER_ML
+ total_cost += cost
+ logging.info(' %s ink cost: %s', counter['dd:MarkerColor'], str(cost))
+ elif counter['dd:CounterTarget'] == 'mediaFed':
+ squareinches = float(counter['dd:ValueFloat'])
+ squaremetres = squareinches / 1550.0
+ cost = squaremetres * PAPER_PROTOCOIN_PER_M
+ total_cost += cost
+ logging.info(' Paper cost: %s', str(cost))
+
+ total_cost = round(total_cost, 2)
+
+ logging.info('Total cost: %s protocoin', str(total_cost))
+
+ memo = 'Protocoin - Purchase spent ₱ {} on printing'.format(
+ total_cost,
+ )
+
+ 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)
+
+ return Response(200)
+ except OperationalError:
+ self.printer_report(request, pk)
+
class PinballViewSet(Base):
@action(detail=False, methods=['post'])
From 3ec76e4cfd95586c0d67112b733eea5bcb68b586 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 30 Dec 2022 23:52:11 +0000
Subject: [PATCH 12/76] Automatically un-vet members away for more than a year
---
apiserver/apiserver/api/views.py | 21 ++++++++++++++-
webclient/src/AdminMembers.js | 46 ++++++++++++++++++++++++++------
2 files changed, 58 insertions(+), 9 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 9ff500f..d323df9 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -196,11 +196,29 @@ class MemberViewSet(Base, Retrieve, Update):
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)
@@ -560,6 +578,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(
diff --git a/webclient/src/AdminMembers.js b/webclient/src/AdminMembers.js
index 8c6addf..37f1670 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,15 +363,45 @@ 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)}
+ />
+
+
+
+ >
+ :
+
:
<>
+ Pause members who are inactive, former, or on vacation.
+
Date: Tue, 3 Jan 2023 20:11:43 +0000
Subject: [PATCH 13/76] Fix object 404 exception handler logging
---
apiserver/apiserver/api/utils.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py
index 0ebb888..a4d91b7 100644
--- a/apiserver/apiserver/api/utils.py
+++ b/apiserver/apiserver/api/utils.py
@@ -476,7 +476,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):
From 02ecd49e85c113d4673231386eee683ee07a73bd Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 4 Jan 2023 18:28:14 +0000
Subject: [PATCH 14/76] Return member's name on pinball card scan
---
apiserver/apiserver/api/views.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index d323df9..450c1c6 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1326,6 +1326,21 @@ 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)
+
+
class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer
From b3668025b37e0b2ed385930607d90f128c59459f Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 11 Jan 2023 18:28:43 +0000
Subject: [PATCH 15/76] Fix usages __str__ bug
---
apiserver/apiserver/api/models.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py
index 6e36e48..b83fc26 100644
--- a/apiserver/apiserver/api/models.py
+++ b/apiserver/apiserver/api/models.py
@@ -221,7 +221,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()
@@ -232,7 +232,7 @@ class Usage(models.Model):
MY_FIELDS = ['started_at', 'finished_at', 'user', 'num_seconds', 'should_bill']
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)
From 7ff628d195f465375b8669828cbb606db3733259 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 11 Jan 2023 18:50:53 +0000
Subject: [PATCH 16/76] Increase usage track username expiration time
---
apiserver/apiserver/api/views.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 450c1c6..b92c9f4 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -780,7 +780,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)
From 890573588674d41b27c3bda5c60d6a12dcac4364 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 17 Jan 2023 23:18:00 +0000
Subject: [PATCH 17/76] Add thousands separators to pinball score
---
webclient/src/Members.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/Members.js b/webclient/src/Members.js
index 8433a25..214bc69 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'}
>
:
From 682feeacf77e9d9477a5154d83a568f969ad6105 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 18 Jan 2023 01:01:27 +0000
Subject: [PATCH 18/76] Display pinball scores on LCARS1
---
apiserver/apiserver/api/views.py | 17 +++++++++++
webclient/src/Debug.js | 2 ++
webclient/src/Display.js | 52 +++++++++++++++++++++++++++-----
webclient/src/light.css | 19 ++++++++++--
4 files changed, 79 insertions(+), 11 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index bbcbfec..aae21de 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1345,6 +1345,23 @@ class PinballViewSet(Base):
)
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')[:5]
+
+ scores = []
+
+ for member in members:
+ scores.append(dict(
+ name=member.preferred_name + ' ' + member.last_name[0],
+ score=member.pinball_score,
+ ))
+
+ return Response(scores)
+
class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer
diff --git a/webclient/src/Debug.js b/webclient/src/Debug.js
index ce62fc4..de288af 100644
--- a/webclient/src/Debug.js
+++ b/webclient/src/Debug.js
@@ -26,6 +26,8 @@ export function Debug(props) {
Trotec Usage
+ LCARS1 Display
+
);
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 471cbc4..bb1bf62 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -34,7 +34,9 @@ export function LCARS1Display(props) {
}
-
+
+
+
@@ -71,17 +73,51 @@ export function DisplayUsage(props) {
return (
<>
+
+
{showUsage ?
:
- <>
-
-
-
- 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 (
+ <>
+
+
+ {scores && scores.map((x, i) =>
+
+
+
{x.score.toLocaleString()}
+
+ )}
+
+ >
+ );
+};
diff --git a/webclient/src/light.css b/webclient/src/light.css
index be09837..a2d17e5 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -179,15 +179,28 @@ body {
height: 100vh;
background-color: black;
color: white;
- font-size: 1em;
+ font-size: 1.5em;
}
.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;
}
.usage {
From e574d71fdbf85fa41b312cb1228e5a3f145925e3 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 18 Jan 2023 01:16:29 +0000
Subject: [PATCH 19/76] Fix lcars display font size
---
webclient/src/light.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/light.css b/webclient/src/light.css
index a2d17e5..ab8987f 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -179,7 +179,7 @@ body {
height: 100vh;
background-color: black;
color: white;
- font-size: 1.5em;
+ font-size: 1.8em;
}
.display-usage {
From fc62da9c5c2d812970058fef8083611d55abbf7d Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 18 Jan 2023 05:38:48 +0000
Subject: [PATCH 20/76] Only display usage on lcars when in use
---
webclient/src/Display.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index bb1bf62..6587c8a 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -69,7 +69,8 @@ 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 (
<>
From 8dcc61817f0bdaebc3815097926c45c6ba92df04 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 18 Jan 2023 17:51:32 +0000
Subject: [PATCH 21/76] Don't log pinball high score get
---
apiserver/apiserver/api/throttles.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index e2aeb57..88c2b7f 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -19,11 +19,13 @@ 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/']:
+ return True
if request.data:
if type(request.data) is not dict:
From c4185b15ca1d9e78b436118329a6dabb1f3cf638 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 19 Jan 2023 18:07:31 +0000
Subject: [PATCH 22/76] Fix member model no user __str__ history bug
---
apiserver/apiserver/api/models.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py
index 0f9ac68..9cd5424 100644
--- a/apiserver/apiserver/api/models.py
+++ b/apiserver/apiserver/api/models.py
@@ -63,7 +63,7 @@ class Member(models.Model):
MY_FIELDS = ['user', '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)
From 32585495be00a24fd88b60dc2e92e924471bc1b6 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 19 Jan 2023 18:31:56 +0000
Subject: [PATCH 23/76] Add checkboxes for unpausing expired members
---
webclient/src/AdminMembers.js | 43 +++++++++++++++++++++++++++++------
1 file changed, 36 insertions(+), 7 deletions(-)
diff --git a/webclient/src/AdminMembers.js b/webclient/src/AdminMembers.js
index 37f1670..267dbed 100644
--- a/webclient/src/AdminMembers.js
+++ b/webclient/src/AdminMembers.js
@@ -373,7 +373,7 @@ export function AdminMemberPause(props) {
>
:
-
+ 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.
Date: Fri, 20 Jan 2023 17:47:01 +0000
Subject: [PATCH 24/76] Only send a maximum of 20 interest emails
---
apiserver/apiserver/api/views.py | 35 +++++++++++++++++---------------
1 file changed, 19 insertions(+), 16 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index aae21de..1ff3b45 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -314,25 +314,28 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
logging.info('Session is in the past or too soon, not sending interest emails.')
return
- interests = models.Interest.objects.filter(
- course=session.course,
- satisfied_by__isnull=True,
- user__member__paused_date__isnull=True
- )
+ with transaction.atomic():
+ interests = models.Interest.objects.filter(
+ 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))
- if data['request_id']: utils_stats.set_progress(data['request_id'], msg, replace=True)
+ for num, interest in enumerate(interests):
+ msg = 'Sending email {} / {}...'.format(num+1, len(interests))
+ if data['request_id']: utils_stats.set_progress(data['request_id'], msg, replace=True)
- try:
- utils_email.send_interest_email(interest)
- except BaseException as e:
- msg = 'Problem sending interest email: ' + str(e)
- logger.exception(msg)
- utils.alert_tanner(msg)
+ try:
+ utils_email.send_interest_email(interest)
+ except BaseException as e:
+ msg = 'Problem sending interest email: ' + str(e)
+ logger.exception(msg)
+ utils.alert_tanner(msg)
- num_satisfied = interests.update(satisfied_by=session)
- logging.info('Satisfied %s interests.', num_satisfied)
+ 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):
cal = icalendar.Calendar()
From 0e629151bab02c9057f421f8ce16e4c339f0bc4e Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 24 Jan 2023 03:43:25 +0000
Subject: [PATCH 25/76] Add crown to pinball champ
---
webclient/src/Display.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 6587c8a..33fdeb1 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -114,7 +114,7 @@ export function DisplayScores(props) {
{scores && scores.map((x, i) =>
-
+
#{i+1} — {x.name}. {i === 0 ? '👑' : ''}
{x.score.toLocaleString()}
)}
From b5f69b6b98c4c584931690c7639c626ecc73ebcc Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 3 Feb 2023 00:02:20 +0000
Subject: [PATCH 26/76] Add summary table to admin transactions
---
apiserver/apiserver/api/views.py | 25 +++++++++++++++
webclient/src/AdminTransactions.js | 49 ++++++++++++++++++++++++++++--
2 files changed, 72 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 1ff3b45..a91bb25 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -557,6 +557,31 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
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):
permission_classes = [AllowMetadata | IsAuthenticated]
diff --git a/webclient/src/AdminTransactions.js b/webclient/src/AdminTransactions.js
index fd24870..afc979c 100644
--- a/webclient/src/AdminTransactions.js
+++ b/webclient/src/AdminTransactions.js
@@ -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,12 +42,14 @@ export function AdminReportedTransactions(props) {
};
let transactionsCache = false;
+let summaryCache = false;
let excludePayPalCache = false;
export function AdminHistoricalTransactions(props) {
const { token } = props;
const [input, setInput] = useState({ month: moment() });
const [transactions, setTransactions] = useState(transactionsCache);
+ const [summary, setSummary] = useState(summaryCache);
const [excludePayPal, setExcludePayPal] = useState(excludePayPalCache);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@@ -75,6 +77,19 @@ 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);
+ });
};
return (
@@ -96,10 +111,40 @@ export function AdminHistoricalTransactions(props) {
+ {transactions && Found {transactions.length} transactions.
}
+
+ {!error ?
+ 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
}
From c2e566bc30d7849b4edb2eeea2d060b7629d398d Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 3 Feb 2023 00:22:51 +0000
Subject: [PATCH 27/76] Increase page size to show more transactions
---
apiserver/apiserver/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py
index 00bddbb..be9ca5e 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'],
From d8e72a81681e65e962963634bfbf07b001f16bb2 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 11 Feb 2023 21:20:04 +0000
Subject: [PATCH 28/76] Export num students and attended in class report
---
apiserver/scripts/export_class_report.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
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(),
))
From 945b3652781ed99fc54d8a2918c8e6800758a7b5 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 11 Feb 2023 21:20:49 +0000
Subject: [PATCH 29/76] Move merge course script
---
apiserver/{ => scripts}/delete_course_merge_into.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename apiserver/{ => scripts}/delete_course_merge_into.py (100%)
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
From 672023f539e7b4ddb7de919d830e04d8ab683bfd Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sun, 12 Feb 2023 00:15:50 +0000
Subject: [PATCH 30/76] Log what vending machine purchase was from
---
apiserver/apiserver/api/views.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index a91bb25..1d37163 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1242,6 +1242,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:
@@ -1276,8 +1278,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,
)
From 7c0b44477ac34192c519359f6f87f41bb1a043f7 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 13 Feb 2023 18:25:01 +0000
Subject: [PATCH 31/76] Switch printer report API to use parsed emails
---
apiserver/apiserver/api/throttles.py | 3 --
apiserver/apiserver/api/views.py | 61 +++++++++++-----------------
2 files changed, 23 insertions(+), 41 deletions(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index 5eadb37..e2aeb57 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,9 +24,6 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
- elif path == '/protocoin/printer_report/':
- logging.info('%s %s | User: %s | Data: [XML]', method, path, user)
- return True
if request.data:
if type(request.data) is not dict:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 8464c05..62fc35e 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1285,27 +1285,11 @@ class ProtocoinViewSet(Base):
#if secrets.VEND_API_TOKEN and auth_token != 'Bearer ' + secrets.VEND_API_TOKEN:
# raise exceptions.PermissionDenied()
- xml_start = len('XML_string=')
- xml_string = request.body.decode()[xml_start:]
- report_json = xmltodict.parse(xml_string)
+ # {'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'}
- jobs = report_json['xdm:Device']['xdm:Metrics']['xdm:JobHistory']['xdm:Job']
- jobs.sort(key=lambda x: x['job:Job']['job:Processing']['pwg:DateTimeAtCreation'], reverse=True)
-
- logging.info('Sorted %s jobs by creation date.', str(len(jobs)))
-
- #import json
- #print(json.dumps(jobs, indent=4))
-
- # most recent job might be an automatic service job
- # so iterate until we find a userIO job
- for job in jobs:
- previous_job = job['job:Job']
- source = previous_job['job:Source'].get('dd:JobSource', None)
- if source == 'userIO': break
-
- job_uuid = previous_job.get('dd:UUID', None)
- username = previous_job['job:Source']['job:Client'].get('dd:UserName', None)
+ job_uuid = request.data['uuid']
+ username = request.data['user_name']
logging.info('New printer job UUID: %s, username: %s', str(job_uuid), str(username))
@@ -1328,8 +1312,8 @@ class ProtocoinViewSet(Base):
logger.error(msg)
return Response(200)
- is_completed = previous_job.get('dd:EndState', None) == 'Completed'
- is_print = previous_job.get('dd:JobType', None) == 'print'
+ 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)
@@ -1352,34 +1336,35 @@ class ProtocoinViewSet(Base):
return Response(200)
INK_PROTOCOIN_PER_ML = 0.20
- PAPER_PROTOCOIN_PER_M = 0.25
+ DEFAULT_PAPER_PROTOCOIN_PER_M = 0.25
PROTOCOIN_PER_PRINT = 2.0
total_cost = PROTOCOIN_PER_PRINT
logging.info(' Fixed cost: %s', str(PROTOCOIN_PER_PRINT))
- counters = previous_job['job:Processing']['job:JobTotals']['count:Counter']
+ 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))
- for counter in counters:
- if counter['dd:CounterTarget'] == 'inkUsed':
- microliters = float(counter['dd:ValueFloat'])
- millilitres = microliters / 1000.0
- cost = millilitres * INK_PROTOCOIN_PER_ML
- total_cost += cost
- logging.info(' %s ink cost: %s', counter['dd:MarkerColor'], str(cost))
- elif counter['dd:CounterTarget'] == 'mediaFed':
- squareinches = float(counter['dd:ValueFloat'])
- squaremetres = squareinches / 1550.0
- cost = squaremetres * PAPER_PROTOCOIN_PER_M
- total_cost += cost
- logging.info(' Paper cost: %s', 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 ₱ {} on printing'.format(
+ memo = 'Protocoin - Purchase spent ₱ {} printing {}'.format(
total_cost,
+ request.data['job_name'],
)
tx = models.Transaction.objects.create(
From d946348fec610ff7588613ea52304438f137bb16 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 13 Feb 2023 23:24:37 +0000
Subject: [PATCH 32/76] Adjust prices, add negative protocoin warning
---
apiserver/apiserver/api/views.py | 7 +++++--
webclient/src/Home.js | 11 +++++++++--
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 62fc35e..3c36a17 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1312,6 +1312,9 @@ class ProtocoinViewSet(Base):
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'
@@ -1335,8 +1338,8 @@ class ProtocoinViewSet(Base):
logger.error(msg)
return Response(200)
- INK_PROTOCOIN_PER_ML = 0.20
- DEFAULT_PAPER_PROTOCOIN_PER_M = 0.25
+ INK_PROTOCOIN_PER_ML = 0.75
+ DEFAULT_PAPER_PROTOCOIN_PER_M = 0.50
PROTOCOIN_PER_PRINT = 2.0
total_cost = PROTOCOIN_PER_PRINT
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index d23558e..cd1398a 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.
+ }
+
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 show_signup = stats?.at_protospace;
From 64e328c1378d8b04b48fddeb0d9f9b62ab421243 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 13 Feb 2023 23:39:23 +0000
Subject: [PATCH 33/76] Freeze requirements
---
apiserver/requirements.txt | 1 +
1 file changed, 1 insertion(+)
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
From b47c773b164ea2ea9e9b20a66714889b54aa22e8 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 15 Feb 2023 00:18:30 +0000
Subject: [PATCH 34/76] Return all high scores from API
---
apiserver/apiserver/api/views.py | 3 ++-
webclient/src/Display.js | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 7a8be82..f7b92e0 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1490,7 +1490,7 @@ class PinballViewSet(Base):
members = models.Member.objects.all()
members = members.annotate(
pinball_score=Max('user__scores__score'),
- ).exclude(pinball_score__isnull=True).order_by('-pinball_score')[:5]
+ ).exclude(pinball_score__isnull=True).order_by('-pinball_score')
scores = []
@@ -1498,6 +1498,7 @@ class PinballViewSet(Base):
scores.append(dict(
name=member.preferred_name + ' ' + member.last_name[0],
score=member.pinball_score,
+ member_id=member.id,
))
return Response(scores)
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 33fdeb1..92bfdf0 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -112,7 +112,7 @@ export function DisplayScores(props) {
<>
- {scores && scores.map((x, i) =>
+ {scores && scores.slice(0, 5).map((x, i) =>
#{i+1} — {x.name}. {i === 0 ? '👑' : ''}
{x.score.toLocaleString()}
From c8b1de5eea72880ccf5be74c23449f219781eb0a Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 18 Feb 2023 02:55:04 +0000
Subject: [PATCH 35/76] Disable dark mode by default
---
webclient/src/App.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/webclient/src/App.js b/webclient/src/App.js
index e6d11de..1edc59a 100644
--- a/webclient/src/App.js
+++ b/webclient/src/App.js
@@ -99,6 +99,7 @@ function App() {
buttonColorDark: '#666',
buttonColorLight: '#aaa',
label: '🌙',
+ autoMatchOsTheme: false,
}
const darkmode = new Darkmode(options);
darkmode.showWidget();
From 43a9595dd32bc22027c46438412f0b519d25c470 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 18 Feb 2023 02:56:48 +0000
Subject: [PATCH 36/76] =?UTF-8?q?Change=20dark=20mode=20label=20to=20?=
=?UTF-8?q?=F0=9F=8C=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
webclient/src/App.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/App.js b/webclient/src/App.js
index 1edc59a..3bdcf9e 100644
--- a/webclient/src/App.js
+++ b/webclient/src/App.js
@@ -98,7 +98,7 @@ function App() {
right: '16px',
buttonColorDark: '#666',
buttonColorLight: '#aaa',
- label: '🌙',
+ label: '🌓',
autoMatchOsTheme: false,
}
const darkmode = new Darkmode(options);
From b8a0effbb2c2e12e86b8cb920c7d2815fe9ab9b2 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 18 Feb 2023 19:26:43 +0000
Subject: [PATCH 37/76] Add last scanned ID to stats
---
apiserver/apiserver/api/utils_stats.py | 1 +
apiserver/apiserver/api/views.py | 6 ++++++
2 files changed, 7 insertions(+)
diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py
index 3adf999..021a722 100644
--- a/apiserver/apiserver/api/utils_stats.py
+++ b/apiserver/apiserver/api/utils_stats.py
@@ -28,6 +28,7 @@ DEFAULTS = {
'sign': '',
'link': '',
'autoscan': '',
+ 'last_scan': {},
}
if secrets.MUMBLE:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index f7b92e0..714f30a 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -629,6 +629,12 @@ 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,
+ )
+ cache.set('last_scan', last_scan)
+
utils_stats.calc_card_scans()
return Response(200)
From 0f5dbee24b2366d8521dca5c5fd13a338accb530 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 20 Feb 2023 02:38:34 +0000
Subject: [PATCH 38/76] Add API route for protocoin printer balance
---
apiserver/apiserver/api/throttles.py | 2 +-
apiserver/apiserver/api/views.py | 32 ++++++++++++++++++++++++++++
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index 88c2b7f..6724c5d 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,7 +24,7 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
- elif path in ['/pinball/high_scores/']:
+ elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/']:
return True
if request.data:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 714f30a..e7a92ce 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1236,6 +1236,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:
From afcf1c34855386dbd15349d269540e3d7280f2dc Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 1 Mar 2023 17:58:50 +0000
Subject: [PATCH 39/76] Return first name of recent card scan
---
apiserver/apiserver/api/views.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index e7a92ce..47af87c 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -632,6 +632,7 @@ class DoorViewSet(viewsets.ViewSet, List):
last_scan = dict(
time=time.time(),
member_id=member.id,
+ first_name=member.preferred_name,
)
cache.set('last_scan', last_scan)
From f5ff777aa948b3dbbb16efeb187f985e6b22d814 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 1 Mar 2023 17:59:19 +0000
Subject: [PATCH 40/76] Remove interest-satisfying atomic()
---
apiserver/apiserver/api/views.py | 35 ++++++++++++++++----------------
1 file changed, 17 insertions(+), 18 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index e7a92ce..a5905a3 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -313,28 +313,27 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
logging.info('Session is in the past or too soon, not sending interest emails.')
return
- with transaction.atomic():
- interests = models.Interest.objects.filter(
- course=session.course,
- satisfied_by__isnull=True,
- user__member__paused_date__isnull=True
- )[:20]
+ interests = models.Interest.objects.filter(
+ 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))
- if data['request_id']: utils_stats.set_progress(data['request_id'], msg, replace=True)
+ for num, interest in enumerate(interests):
+ msg = 'Sending email {} / {}...'.format(num+1, len(interests))
+ if data['request_id']: utils_stats.set_progress(data['request_id'], msg, replace=True)
- try:
- utils_email.send_interest_email(interest)
- except BaseException as e:
- msg = 'Problem sending interest email: ' + str(e)
- logger.exception(msg)
- utils.alert_tanner(msg)
+ try:
+ utils_email.send_interest_email(interest)
+ except BaseException as e:
+ msg = 'Problem sending interest email: ' + str(e)
+ logger.exception(msg)
+ utils.alert_tanner(msg)
- interest_ids = interests.values('id')
- num_satisfied = models.Interest.objects.filter(id__in=interest_ids).update(satisfied_by=session)
+ interest_ids = interests.values('id')
+ num_satisfied = models.Interest.objects.filter(id__in=interest_ids).update(satisfied_by=session)
- logging.info('Satisfied %s interests.', num_satisfied)
+ logging.info('Satisfied %s interests.', num_satisfied)
def generate_ical(self, session):
cal = icalendar.Calendar()
From 7112b19cca8955b408e661293a3a76cbf82cfa4c Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 1 Mar 2023 21:40:45 +0000
Subject: [PATCH 41/76] Add models and API route for hosting new members
---
apiserver/apiserver/api/models.py | 13 +++++
apiserver/apiserver/api/utils_stats.py | 1 +
apiserver/apiserver/api/views.py | 66 ++++++++++++++++++++++++++
apiserver/apiserver/urls.py | 1 +
4 files changed, 81 insertions(+)
diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py
index 9cd5424..7f168ef 100644
--- a/apiserver/apiserver/api/models.py
+++ b/apiserver/apiserver/api/models.py
@@ -250,6 +250,19 @@ class PinballScore(models.Model):
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
+
+ MY_FIELDS = ['started_at', 'hours', 'finished_at', 'user']
+ def __str__(self):
+ return str(self.started_at)
+
class HistoryIndex(models.Model):
content_type = models.ForeignKey(ContentType, null=True, on_delete=models.SET_NULL)
object_id = models.PositiveIntegerField()
diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py
index 021a722..f81c811 100644
--- a/apiserver/apiserver/api/utils_stats.py
+++ b/apiserver/apiserver/api/utils_stats.py
@@ -29,6 +29,7 @@ DEFAULTS = {
'link': '',
'autoscan': '',
'last_scan': {},
+ 'closing': {},
}
if secrets.MUMBLE:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 47af87c..1a51acd 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1543,6 +1543,72 @@ class PinballViewSet(Base):
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)
+
+
class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer
diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py
index fc13f9b..2e34671 100644
--- a/apiserver/apiserver/urls.py
+++ b/apiserver/apiserver/urls.py
@@ -20,6 +20,7 @@ router.register(r'courses', views.CourseViewSet, basename='course')
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'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')
From f0e012cc0352829f171bcfeef5eb81dacaaaad71 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 2 Mar 2023 04:36:40 +0000
Subject: [PATCH 42/76] Add hosting status to home stats
---
webclient/src/Home.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index cd1398a..fbd9be4 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -242,6 +242,8 @@ export function Home(props) {
//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 show_signup = stats?.at_protospace;
return (
@@ -356,6 +358,8 @@ export function Home(props) {
{user && Alarm status: {alarmStat()}{doorOpenStat()}
}
+
+ {user && Hosting status: {closedStat()}
}
From 28cdf64f76ca798060175600367b5f02df8f93ed Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 9 Mar 2023 17:34:08 +0000
Subject: [PATCH 43/76] Add hosting high scores to LCARS display
---
apiserver/apiserver/api/views.py | 18 ++++++++++++++
webclient/public/toast.png | Bin 0 -> 8263 bytes
webclient/src/Display.js | 40 +++++++++++++++++++++++++++++++
webclient/src/light.css | 6 +++++
4 files changed, 64 insertions(+)
create mode 100644 webclient/public/toast.png
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 1a15b25..405dbd4 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1607,6 +1607,24 @@ class HostingViewSet(Base):
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 RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer
diff --git a/webclient/public/toast.png b/webclient/public/toast.png
new file mode 100644
index 0000000000000000000000000000000000000000..0f59c62eac3417f6343c579060923d9e365efacf
GIT binary patch
literal 8263
zcmd5>(^n-97fn-5o@%md@?_g~H{0f9+qP}q$u;?AOt>}Kwt2(%`!l|?*4g`Iuk*C`
z+7BmMMM)YJi2w--3JO(LMndgBHvex85n=zs-3`%tC@5Gc6$OB#w6nt5-^A0&=(~m3
z;Xid}BY~&mVMjxOmosr!f1~b}(l$FiFXvPD21AZUBaX(RAJ?<{OQUWVlXeHgpLdJ;
zE5dKUi4SX8`=fEoEuLr7{|PeIyZoPb^1G6)AC{A^=9A`{!m@nr(#`mGL17d5R%_j1
zWzG^UQHC>h9s$bq{;JHNJjc2Ah$u59KMnrsUjnmb){~9l>-E+bbIH@WM%kuZNji)%
zT8tB!I`8LoWv+6)$wqY{x_M@t*$#4%=1P-QE|pH=d0Mm)#!T5tJ_VP9~r
zO0kO!C?0S<>eHKPF`cSBpADEQHi^~cTCOnvB|$mU%knZnr1?O{X_^r5<66HRW`Yc(xM$V7Pd_F=?+S1JI7~0^~OA$GVv>wfo!bw>jBxDBn_n
z3OHYRC|WE{nfMnw2GHimNw3RBnc8x;WTPcDxXk!!(DArYd$(K>8cERhOGw;$(@Mz4`pO(1#Ob{uUX|mfI%D6E7t4
zO=Rj9>QSDx8x@%{AGBBiomu+bXr|(%wz~tay3Ov#JyxoWI>W?EY`Mn^tvBl|I~^#1
zzEU~5bZy~Ei?wc64Cp=afSwe?FnMxcQDX3F|LK2`xN=idQipnYfB*9F@d5cbf4}^I
z+@GL5L3Z)(b|7z;H}4CokR#e3JJ)U!&vqI8F(Njcz8{gCHJe3{x2yKKwc6zKK>644
zX@kbq9^dEul$w*(ZF6d@ZG6{3s@hm}K0n^Gbw4^iJ#BMS!YN{T_|lUo-v-Cuo)#<$`aTAA
z<+O$BpuSxokdM?PMx{PCo0PM*e2J@MmvIq(0d|EwpH1cmS}=C5F8euMEAA~@Ky>6JP3=9i(%->1Cl`IG|vdnIL-aeZc$
zmXJ?Nrv5LNfO}d@DQCEp$Zx7lPn0~c#z_kG!60iqaf!H!FQv%|tEG=T%qn?Z{Fp%;KHo(_>@Dq@F!p4)F+P!#r6q;+4DMVtHh|F@r8~Y%qYa8rFXJ
zMAF&UHOSRZUZN5`iS>?Tc(^~rL%3>ivKVl%qEQGW_?=6hWgi+{eBGKcDJY;hG9D7<
zvRlF*A_Ld+byy+w(Gov0LyIudY5}g_Irc|b%Z3nV!bLH*vthz$$?;sG!e=E+uh*;F
z+j^1I%Hbr;txH9yxKjs=d&gKg#8Wt;i+YowQNxRXN{;!lib`lybwWtdz%mic8)Itp
zRmtu0c+)PK&JC{GMI1X)!w!h%I|JD-l6-Q&AT7D>@;b_hHyQ{J&Qyld)Oi3N+cl+(
zT_N1T@JCH*ByA2uFn$ug5W$!^3`eMoQbLiRs~c7E1s+%7>s>jea8`g~s8;=Hl9n5u
z4r4_~kpcAIZ1Ha>zarj&V!<(4sT763P`FS`fSBVJbP?aG7?bNYwMx^_q3`;W^VV%O
zPN#d8nB5IOL_xL$OO8*iw!{R)*+^-zk7~S0@fu_@3zGV57gacqk@+ywEFIle0meb!
zn}|dasmz9Wnk0gASZig`)&`x$U}@4o>{^cckn6us^3@J+pp!rCd}~tsCUN_6R2-9P
zcmyjp;trav_BIO)ts%@PVW$d$Pa5>tp{|VMUv*0RS#;*3$#}Joq_K1}ZH97Gl#Gsh
z-OuJH7B{xAt{a*C-OkQ39_Qo`OEEZhVMi_4v(1}^z1Qj#!M;7xtZX>3K<~|UZ<~YstDJwo?Fs|@9t+X2;WNJ%vl#G1
z4jOk8WFmivalYK2^gFkAcJ^$o^Do#HXr0#c8K6yMXy+>mt3b1jlIwkWh;{}1W94e-
z_PmUJY`7sTG%$Qg=(i>C!G(t~-=hwGA36zho~mc}wr|J+;Hc}GqaRxN;95mYgP$Oc
zEpF{?Eeg|<`&Cai?u%b_9+(g*Wj+2?i>rDsvlM68X5vH~I&Ja%dcB>H+mlavEp@=c
z%8B+RK{T7dhIv^hZ}pj4U*r53D^nE$x+%W9>i%Q5?{%qp8N3Mpce!ID$gjuLOjaxt
zVcxl(YKxCN>K71hY6IYRX*lyZTVbzs+U&k_?Q8omJaryh5=2a$vBD!GUoTY2zT)Pt
z?PkNZe`qs2J2(9I7{I=`_|NZMOe@#I(zPikx`|Or5`6MsT@#Q6?W#tKlWR04wsCG<
zyq(`p*9Q7Lv+Z+FJh&(N5$}}bhC_+&nUtBP8t}IJsm4S&)ErbdTYb(G#}-dM`A+-S
z`)}aLC)!%NaEVy1xN?vxz$79(Km}qP$fLEI?e1R3vy*P}2JVL5NgEQ32NaH8J?=eX
zlPZ9l5zBswyJ{CN3^@>N>R_;8PAoy
zbPG;*f~r=xNr?@QorKoB-}c8-j{+fsadzk?0ex$VqoD)FDX|^j@|EsIlP1f+xg0EM
zzib)?;BZB)C;+p=Mxm$A~c{t%XFk;M;B>pr59OCG}M^$A7?ZPfv&rMUvO`0go9$w0=NfgLcLQa
zJIxLA2M%&_-E+E;5~cfSFMt9G)dt^|p0yBYD$PrgdU^-HaPhR|LFWqI*|TTG(IDFl
z$c*<)$kqz&K-pQHKGTrC?Va*7i{cG!kB%#lXB~wR+2@b~SNxjmX86Gs8u2F*;QOR<
zSc7!;mKMfQOAv7BJ2cW?Z1xilbRvjOQ&L=INq+)HxY!V-?dZIi)|s>xsZe+K*;djL
z`nE~ysI~IZ3(SCOmOU%XxIW_7;k0>|A4Ind0NY93x+oosP~zX6NcP`#xWwVd7UQW3
zH{n82sP9eyzw^RXI!Bocv+%k*oKL4FK#_q?UaZgDbfYOoGT&2zp_UV(rVk{3(xbBh
zxla7CV_){ETL*6sbki4w3+@6O@quyElG^mSsODVH|JeyAWWdVz?nUgzNI!E;Imeq|
zw3J^`FUHbteqo5imtB^bD>wUM)4r*Bd`h1qwR8D=1I~}PkGW_#QxaMa&FP>jL_@S+
zj2B1VqzYE7bZS?
ze5&r@0DL*;TVD=NGH88|EP(uHjV=G>z2!9xzGy~?Ga<(T%p7#|P{zK_JjG1($z*0T
z4taf6e>=mYic3fPEeQxW>+SwgeDY%aQ!#|QGgTKIuE&>~0mdg&&q+C5$S(kC5cLlp
zwwpr7dI49B00EbGILGo#^ib`h4E3?qnv7ZUiLv3y3KuS>51_JfZP|v@gN1Hp5%F3c$B*icxJ}OJUE}h9=jr{Zo!{_lmu7G#~
zcgZU#cNTeB;a|jEo7cC^5|p)X>7vPBg>Lfx+<pe4bww?(N(z%#Qj~(VTwi~RT
zSu{+XSi5}cdgRz@I32aI(HD#Lg_>ATe^&A46iG?PJ%ufpuyk$5{oAgTEZ#{!x~v{#
zQ7$f`>vO-9gF7ium2c(QDls1I*e_GX*|A%O+^wr{OH^?a4YNt}muhbq!kHW^F%{Ek
z^kONW(w&g8Phsryzd%u@F8ofYrvWHA=J6p2b(*jdXcS1JlRV<4U2FmYr~2Ykj@5ZR
z`(bmsrcIsxGMKSDb3bGbZaATmrd2?`2?ZAyMuhhK_vW=h74NV$zecQy=HrtODPZ(B
zHhgJ}@;0xHvH|pMLnQ7k?e$(g!-9S))M4wadbZQ3xu)gB!h?Z#QfQK~43eH|xaP1c
z#Gx(A!v_&kV$p@tueiAUi9&Z12SYk$YOK?7YZJ>|U4MiR8ww))dMcqMi$*yG;-r9X
z;I)Rt=I3Na#9Mz4I#rTk`5o8JWshHwX2G8bNyBP7GIsq>o{tO=PvmfomTcl&hE&$4
zxF`uzr6)-W^WY%Aqfw`TK;7fMM_ZAb<&@YEPaXw<&p}G1&32=IAg)}SlvA(etN1~F
z{v9Rh9cvTy1Sn`plHK2BCijrE;tMg_?UaWxmzkOF?JYPxw_QsKlpcBgj-
zs4z7yE&f|F%z1r_TQ?}B>l)2Wm70Nxb9Sphc$Q@#oJ$=MA&%{8hs^G{u{PNxKraeL
zwG}qR+YZgx1zzY}&?$(VrF
z5LRskgtg|?#mp<3Ic$m;7zh8`OMr6`xUZz7z1-P(STSPRWK0a6b^ewsg#5C;B`7Hr
z1=Kj?R1K4u91fFz!v(Tuz+(JUJJfb0OTR-3hc8N0B9@SnMmYLvqZY=&yjSaKy;?S)PW~1TJ+H$+`OrOvhTXY%$YwQ0)n^nHYq!&r2Vzu&^;6Feb9i13^
zOV|}63z#BmipuIMJIekH;N)C2Xzf8fZ|n)iX#wW`;AS~*CNLw5w|P@H1CtkDYK`6A
z>Wq=rZlw}7Y#+){`8eeUk9t3`c!L;eCd)zYJvF!=fpt*f0?7{dOkjkB_7jgW8{
z*5M+aHtKgfNBg~>quz(Vc=#)tDZcX#^7J&ebGss@*<*Q=B0=_nCPnwzvr-}H#A8RSqxKQ#PJ0FN|xz#xz0
z%1TXJcb&FqJk83f+Z+P_xThM<{ttP)iw-fgRtIY?aK_gS40&%JM&NJ7KEmA7
z5G8K>M3*S+J%a3o>v@QgJX`Zj7TG6`!W;yC*f~0uo;%igRTMIAdOttwJ+1B62zCH3
zv;Q1DU3HzGMGy5ztO&JZ=IQDA{(equB~Je3&?VLd8k7+-#S6VY_fAT5;%&C6b{y2T
zj$r(z^D_<6Cvk&hW@>#(JfCUi-3j-fXRZ9>it+XK1*O&K3tLC$k&>4csiN@fB*L+^pW?}N1XcLO?+q(8sbtFn^Da#|i*_3#s-U-_Ij
z3oA56=KpnX^o1CX{@F%7^OU)C0)*T#FkGHkfg3;G*NLB`OXa!REYxMv%9s-HX+K6@
zsJr)zTnN}em@5MGH;Bw8S45XO%I6N(JE^23@K@^_81*hzA+f}x>xBG&S&mk#>>HOe
zFPaNZMwM|21)BQ|P$6azUP5X_>js&g=AdrpyH62qEMpiEq-6GBHRMACjDAKPtB|wJ
z1C0%?zdxN-BkKoVlpU&p9k_xMMe8F|Qwl5{Y(0;A6YCSNJ{{n>nhPHOBp+Jx*$6ry
zS>DWep~qKiB9=WK9!54vI88dZ2y(h!M`dD?j7fP18eCH)y8tF^r1Q@8wM~K3r(SJC
z|A*&+ZpWGn4B5poq|Xd?
zQ7PL83Rey@i|Nc7kN7V*FLQ=i(#H#yN4PO~cPxGbSwN&r$8k{I-h#uO7y8;stx|b!
z$#BpzANU%m^wVQJon}NI<#H%%Yy{ilk?4aYBHEN^LW@FXOrfWZ+t-F&N|U89-o12t
z$^i5`auhi7oE;t79u@2ChF6EKN|YQ`r=A&Df43)Fvty1E;bnr?m4-P}7=On|O$d>0
z_PnoncOjBIJ7dD3kdYMAt}hS®k%>wuyqc=DXxJkU%DcU)IK{+)eAz*b8^Uu|1>
zloE4m=nd@KaO06nk>I$Uh%#oov5dkagAyDP65i^HYPp)1wj6@0p#0YSHxC^OBg5D>
z$CJ0lUazMi!66b^%h1v5;aI+j+b#a1v#1xy0U3bjL*XWWe
zu}^(>e?R#svOO)7jOwU3a_T!cDExkUJ;o|Eh%i#>)0R9jovQGW>)&)-o_}qHY1XNA
zx8!F9c*`{>4+$$Naut9H?O*3Os`E025sP9sxy`9JGe%#rIlK=_=_nI!5aASq`dh2J
z$Oc;MyQq?QrGH`{)ba0nHaku52dse!1d^KJM9(iTUwD;FWb8TkEON6g)s>}{OtjOc
zCRS1@TrwJGdedkNcP1Xtr#n!=-*-Xo!x8{`w(}5?T29keg({FsiTi`a;F+n6)Y!ik
z5wiB-2sNZhzKqjGesYAIoeoQqA2L#Nhy_vd@0&|j87GA!SuHoa`$Nxg^*Q7s8ekp^
zuAHBiA~Ul*RPCm!s;c_C$qeMDh7A&?0M6{hqg!tdQU_Z!CP1v~Ym{f@Ex9yKY_okI
z^LOQ#>}J1>WpTpBNt?IzabUGL_o{rw-H=4G-kX*R?>`uO2>
z9@tN7^?HrUAg&|yY;M0x99Q*4zkJaixkAxdzC415B-ri!m~OXLha8NRW%!#`7PbN)
z!ONN`rJk=MWQ%$nDZDk6JO~jDCZjqjGuq}H&3@QMl`d?1+j+u)suttW&~Qa?$T-EZ
z)(EGaUH($ZWK@vDwzJ{58jKTe*tjg59NVMTsO5^m=RQ$zMoSQXi6E9uF{5=NW0G_L
zaSgs==MGe#P>ioresgiPg|Qhx-N-H~-CAXuZHv$hE7oqI4_14u1*)ZY=xP5$J1?`h7EEbCO
zo{c`J2Bpp0bm%?=`;2lZ8Fbrs9+yTM`0*%Aisv!iJ2>Q$d?`(rD^;g1J+Loz(dERS
zYE-<2Zq#HgL%Tc9?BfF?2Y#o>*pt^1W_b=m6uf>ptz`MPYtk76J4O{SZ3+y6vS!`J
z62eLVcU1dMFaJc*U1k=QEhAfq%8QRAhs$m`W_DnAXQ`o}F5BFVojB?XHm3vaT-0iz
zBEnVLj77Gvq{Dx#)qn5ia1Fl^O3jv~PR(v--&0^qT|sd{k|e*u;6xpkKos<99yGKR
zE88&eS&?BPBm}~>G||Kc*LSDw!XsG=f#pcicfS-^C5orMlVVuQ@<
zmL6-*$Pg(E^3D(aKb*B5&Me}*Hg)uQCU}g4M6uctqDr~+bWz`}>%c9`uo%%4GnAy5
zC<%A>{ayN(Qmwkx{sje)Yx<+hK+jlb!kw=m*i&Q#If~42`y49fvzRI8t`KuY5QiqU
zUz+$Z#|)SPNVXw@<`s-%p<~r8WNhq*TI}F_k=@q$$7Ob9a?D28H+mq->w8PYiSmxL66c8WYB
z^y;|Jhjn?+`kK!&1`HZANTo8K5p&l6ww|TwW>8_FgN3iC4jco;
zyU@${n~#a(WJuVIEZsbDW(iPi?ZQ>no8gLzR_UakTUHA@#~s|&AB^At%^K2w|FV8z
z+oeJq#LRq(e%a&Dm(yTXS-rt1sU7}e-lQGyFbt`!t!=a{T!^h+Fs#&EsRD3yZKZY9
zyW08w>Fv;NsO+lm=>gP$Sv|p)iyYG*PM%iEk+&K=-@1I0+3dt@Og;+FS5(=2
zKo{x_e0&8r1`rAI@(S?s@#*sEF{I~!`cMne3ky*S6B9(&{8k0dcWjzF0s6E|0*YReQWk4Tu-=X4>Sh|ugKu^Tba);~
zcd$1mlIp{YRX~rx90+>9az%$|3`f3`+6g;;fe8+P)Qbq<9V4*g#f|QXT4SY#CeOvj
z#>$+JXgz)yQ(&LQe_kzkEN>yg4V+1A3pdEU1nx3pIn$U3_nGSb_YH-Tl~j^w5Hk+>
EAAY2x@Bjb+
literal 0
HcmV?d00001
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 92bfdf0..72939f0 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -38,6 +38,10 @@ export function LCARS1Display(props) {
+
+
+
+
@@ -122,3 +126,39 @@ export function DisplayScores(props) {
>
);
};
+
+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 (
+ <>
+
+
+ {scores && scores.slice(0, 5).map((x, i) =>
+
+
#{i+1} — {x.name}. {i === 0 ? : ''}
+
{x.hours.toFixed(2)} hours
+
+ )}
+
+ >
+ );
+};
diff --git a/webclient/src/light.css b/webclient/src/light.css
index ab8987f..f7b9abe 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -203,6 +203,12 @@ body {
text-align: right;
}
+.display .display-scores .toast {
+ width: 40px;
+ height: 40px;
+ margin-bottom: 15px;
+}
+
.usage {
height: 100vh;
background-color: black;
From e00bea9faa148d38ed2c48353d23114bdbb9a7e5 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 11 Mar 2023 01:47:08 +0000
Subject: [PATCH 44/76] Don't log hosting high scores route
---
apiserver/apiserver/api/throttles.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index 6724c5d..cdaabe1 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,7 +24,7 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
- elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/']:
+ elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/', '/hosting/high_scores/']:
return True
if request.data:
From a46021180935fd8cb8a095f234286045720582c1 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 18 Mar 2023 23:42:09 +0000
Subject: [PATCH 45/76] Add 3D printer status to stats
---
apiserver/apiserver/api/throttles.py | 2 +-
apiserver/apiserver/api/utils_stats.py | 1 +
apiserver/apiserver/api/views.py | 17 +++++++++++++++++
webclient/src/Home.js | 6 ++++++
4 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index cdaabe1..a5d1348 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,7 +24,7 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
- elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/', '/hosting/high_scores/']:
+ elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/', '/hosting/high_scores/', '/stats/ord2/printer3d/', '/stats/ord3/printer3d/']:
return True
if request.data:
diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py
index f81c811..832c162 100644
--- a/apiserver/apiserver/api/utils_stats.py
+++ b/apiserver/apiserver/api/utils_stats.py
@@ -30,6 +30,7 @@ DEFAULTS = {
'autoscan': '',
'last_scan': {},
'closing': {},
+ 'printer3d': {},
}
if secrets.MUMBLE:
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 405dbd4..cdb5fe2 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -953,6 +953,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
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index fbd9be4..b865298 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -244,6 +244,8 @@ export function Home(props) {
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[x] ? stats.printer3d[x].state === 'Printing' ? 'Printing (' + stats.printer3d[x].progress + '%)' : stats.printer3d[x].state : 'Unknown';
+
const show_signup = stats?.at_protospace;
return (
@@ -357,6 +359,10 @@ export function Home(props) {
} trigger={[more]} />
+ ORD2 printer: {printer3dStat('ord2')}
+
+ ORD3 printer: {printer3dStat('ord3')}
+
{user && Alarm status: {alarmStat()}{doorOpenStat()}
}
{user && Hosting status: {closedStat()}
}
From a28de294fa5ca6f9f33f25d7d8b5b5ddeac71cb0 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 18 Mar 2023 23:59:36 +0000
Subject: [PATCH 46/76] Fix cached stats not having 3d printer status
---
webclient/src/Home.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index b865298..3b68d39 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -244,7 +244,7 @@ export function Home(props) {
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[x] ? stats.printer3d[x].state === 'Printing' ? 'Printing (' + stats.printer3d[x].progress + '%)' : stats.printer3d[x].state : '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;
From bc41a71219bd6b23dff5823eb9b434e61b6bd733 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 27 Mar 2023 18:45:52 +0000
Subject: [PATCH 47/76] Reduce /user/ queries with select and prefetch related
---
apiserver/apiserver/api/serializers.py | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index 8deecb2..e770d01 100644
--- a/apiserver/apiserver/api/serializers.py
+++ b/apiserver/apiserver/api/serializers.py
@@ -713,6 +713,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()
@@ -738,12 +739,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
From d7aa8c824e43d20065c53f5d8ebdd4280151ba73 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 5 Apr 2023 04:54:49 +0000
Subject: [PATCH 48/76] Add LCARS2 display
---
webclient/src/App.js | 6 +++++-
webclient/src/Debug.js | 2 ++
webclient/src/Display.js | 41 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/webclient/src/App.js b/webclient/src/App.js
index 3bdcf9e..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
@@ -130,6 +130,10 @@ function App() {
+
+
+
+
diff --git a/webclient/src/Debug.js b/webclient/src/Debug.js
index de288af..350719f 100644
--- a/webclient/src/Debug.js
+++ b/webclient/src/Debug.js
@@ -28,6 +28,8 @@ export function Debug(props) {
LCARS1 Display
+
LCARS2 Display
+
);
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 72939f0..92df7cc 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -50,6 +50,47 @@ 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;
From 03056d559f793219c7867aec51f7fc55efd5828f Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 6 Apr 2023 02:56:48 +0000
Subject: [PATCH 49/76] Remove trotec stats from LCARS2
---
webclient/src/Display.js | 4 ----
1 file changed, 4 deletions(-)
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index 92df7cc..cb9ebe2 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -82,10 +82,6 @@ export function LCARS2Display(props) {
-
-
-
-
);
From 9224c546a97563a2e802756a5ff58b972c2f85b8 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 26 Apr 2023 20:47:02 +0000
Subject: [PATCH 50/76] Add media computer track stat and last print
---
apiserver/apiserver/api/views.py | 12 ++++++++++++
webclient/src/Home.js | 20 ++++++++++++++++++++
2 files changed, 32 insertions(+)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index cdb5fe2..d6c2ddc 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1473,6 +1473,18 @@ class ProtocoinViewSet(Base):
)
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)
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index 3b68d39..ccaee22 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -359,6 +359,26 @@ 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')}
From ef6cefe9aa4e86ceae3623dcf166d67b410d1fcf Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 27 Apr 2023 01:36:57 +0000
Subject: [PATCH 51/76] Fix protocoin current balance floating point precision
bug
---
apiserver/apiserver/api/views.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index d6c2ddc..2519339 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1190,7 +1190,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:
@@ -1324,7 +1324,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:
From 67276a7e498597f19260de66d96a3f1ec1367f4b Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 2 May 2023 05:50:22 +0000
Subject: [PATCH 52/76] Send overdue emails
---
apiserver/apiserver/api/emails/overdue.html | 26 +++++++++++++++++++++
apiserver/apiserver/api/emails/overdue.txt | 19 +++++++++++++++
apiserver/apiserver/api/utils.py | 13 +++++++----
apiserver/apiserver/api/utils_email.py | 26 ++++++++++++++++++++-
4 files changed, 79 insertions(+), 5 deletions(-)
create mode 100644 apiserver/apiserver/api/emails/overdue.html
create mode 100644 apiserver/apiserver/api/emails/overdue.txt
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:
+
+
+ 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/utils.py b/apiserver/apiserver/api/utils.py
index a4d91b7..9b3442e 100644
--- a/apiserver/apiserver/api/utils.py
+++ b/apiserver/apiserver/api/utils.py
@@ -115,10 +115,15 @@ def tally_membership_months(member, fake_date=None):
alert_tanner(msg)
logger.info(msg)
- if status == 'Overdue' and previous_status == 'Due':
- msg = 'Member has become Overdue: {} {}'.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)
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)
From b9c8fd5b4c451054442765fd90512cf37547a57b Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 2 May 2023 06:09:58 +0000
Subject: [PATCH 53/76] Display monthly high pinball scores
---
apiserver/apiserver/api/views.py | 21 +++++++++++++++++
webclient/src/Display.js | 40 ++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 2519339..5736887 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1570,6 +1570,27 @@ class PinballViewSet(Base):
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'])
diff --git a/webclient/src/Display.js b/webclient/src/Display.js
index cb9ebe2..a4388f9 100644
--- a/webclient/src/Display.js
+++ b/webclient/src/Display.js
@@ -38,6 +38,10 @@ export function LCARS1Display(props) {
+
+
+
+
@@ -164,6 +168,42 @@ export function DisplayScores(props) {
);
};
+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 (
+ <>
+
+
+ {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);
From 42fa0f184466f9c1ccccd4bd99a1c86a6898fa09 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 2 May 2023 17:49:39 +0000
Subject: [PATCH 54/76] Fix bugs from unreporting printer transactions
---
apiserver/apiserver/api/models.py | 2 +-
apiserver/apiserver/api/serializers.py | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py
index 7f168ef..7ccd119 100644
--- a/apiserver/apiserver/api/models.py
+++ b/apiserver/apiserver/api/models.py
@@ -72,7 +72,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)
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index e770d01..1def532 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)
From 51b9aa2b3f1d1201022876b47ac28a301a1cf464 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 2 May 2023 17:51:51 +0000
Subject: [PATCH 55/76] Ignore logging monthly_high_scores route
---
apiserver/apiserver/api/throttles.py | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index a5d1348..042c5e7 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -24,7 +24,14 @@ class LoggingThrottle(throttling.BaseThrottle):
return True
elif path == '/sessions/' and user == None:
return True
- elif path in ['/pinball/high_scores/', '/protocoin/printer_balance/', '/hosting/high_scores/', '/stats/ord2/printer3d/', '/stats/ord3/printer3d/']:
+ 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:
From 5b64557d745cab2d657c84013dad6c0f49a59b41 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 2 May 2023 18:09:16 +0000
Subject: [PATCH 56/76] Remove manual transaction reporting, no one looks
anyway
---
apiserver/apiserver/api/views.py | 11 ------
webclient/src/Transactions.js | 66 +++-----------------------------
2 files changed, 5 insertions(+), 72 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 5736887..ef63249 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -544,17 +544,6 @@ 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
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 (
-
-
-
-
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) {
:
-
+
+
+ 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.
}
From e1d4de0ea2e6668af728ecd51fbeb18703b4c919 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 3 May 2023 18:10:12 +0000
Subject: [PATCH 57/76] Fix django admin search
---
apiserver/apiserver/api/admin.py | 7 ++--
apiserver/apiserver/api/models.py | 57 ++++++++++++++++++++-----------
2 files changed, 42 insertions(+), 22 deletions(-)
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/models.py b/apiserver/apiserver/api/models.py
index 7ccd119..8d1f38e 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,7 +63,8 @@ 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 getattr(self.user, 'username', 'None')
@@ -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)
@@ -230,7 +244,8 @@ 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 str(self.started_at)
@@ -246,7 +261,8 @@ 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)
@@ -259,7 +275,8 @@ class Hosting(models.Model):
# no history
- MY_FIELDS = ['started_at', 'hours', 'finished_at', 'user']
+ 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)
@@ -279,7 +296,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)
@@ -290,6 +308,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
From 30a820f302fca17d34fdacdc6852c815eba3a9e5 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 3 May 2023 18:57:48 +0000
Subject: [PATCH 58/76] Add option to filter snacks from historical
transactions
---
apiserver/apiserver/api/views.py | 32 +++++++++++++++---------
webclient/src/AdminTransactions.js | 39 ++++++++++++++++++++++--------
webclient/src/light.css | 4 +++
3 files changed, 53 insertions(+), 22 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index ef63249..5fbe3f9 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -499,19 +499,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()
diff --git a/webclient/src/AdminTransactions.js b/webclient/src/AdminTransactions.js
index afc979c..c19c5b8 100644
--- a/webclient/src/AdminTransactions.js
+++ b/webclient/src/AdminTransactions.js
@@ -43,29 +43,24 @@ export function AdminReportedTransactions(props) {
let transactionsCache = false;
let summaryCache = false;
-let excludePayPalCache = false;
export function AdminHistoricalTransactions(props) {
const { token } = props;
const [input, setInput] = useState({ month: moment() });
const [transactions, setTransactions] = useState(transactionsCache);
const [summary, setSummary] = useState(summaryCache);
- const [excludePayPal, setExcludePayPal] = useState(excludePayPalCache);
+ 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);
@@ -92,6 +87,22 @@ export function AdminHistoricalTransactions(props) {
});
};
+ const handleSubmit = (e) => {
+ makeRequest();
+ };
+
+ const handleExcludePayPal = (e, v) => {
+ setExcludePayPal(v.checked);
+ };
+
+ const handleExcludeSnacks = (e, v) => {
+ setExcludeSnacks(v.checked);
+ };
+
+ useEffect(() => {
+ makeRequest();
+ }, [excludePayPal, excludeSnacks]);
+
return (
:
Error loading transactions.
diff --git a/webclient/src/light.css b/webclient/src/light.css
index f7b9abe..323d0bd 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -236,6 +236,10 @@ body {
text-overflow: ellipsis;
}
+.filter-option {
+ margin-right: 1rem;
+}
+
.footer {
margin-top: -20rem;
From 945afebb9926e44f610d59b3b163c43f0fab3fe2 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 6 May 2023 22:55:05 +0000
Subject: [PATCH 59/76] Display all previous classes for a course
---
webclient/src/Courses.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 =>
From 395dbe4418e47368d83a5c907454f3c73a16a8e3 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 6 May 2023 23:21:25 +0000
Subject: [PATCH 60/76] Add warning to members who haven't taken an NMO
---
webclient/src/Home.js | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index ccaee22..77ceeb9 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -94,8 +94,6 @@ function MemberInfo(props) {
}
-
-
{unpaidTraining.map(x =>
Please pay your course fee!
@@ -103,6 +101,12 @@ function MemberInfo(props) {
)}
+
+
+ {!member.orientation_date &&
+ ⚠️ You need to attend a New Member Orientation to use any tool larger than a screwdriver.
+
}
+
{lastTrain.length ?
@@ -115,7 +119,7 @@ function MemberInfo(props) {
)
:
- None, please sign up for an Orientation
+ None
}
{user.training.length > 3 &&
From e0d6ba8b7881b206b418516ad15586202c44c07c Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 6 May 2023 23:32:47 +0000
Subject: [PATCH 61/76] Add NMO status to admin vetting list
---
webclient/src/Admin.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/webclient/src/Admin.js b/webclient/src/Admin.js
index efe5750..642722a 100644
--- a/webclient/src/Admin.js
+++ b/webclient/src/Admin.js
@@ -77,8 +77,8 @@ export function AdminVetting(props) {
Name
-
Status
+ NMO
Start Date
@@ -88,11 +88,11 @@ export function AdminVetting(props) {
{(displayAll ? vetting : vetting.slice(0,5)).map(x =>
{x.preferred_name} {x.last_name}
- Email
{x.status || 'Unknown'}
+ {x.orientation_date ? '✅' : '❌'}
{x.current_start_date}
From c6681f40dbadf6bebd5459fd2447adf49db9a674 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Sat, 6 May 2023 23:47:03 +0000
Subject: [PATCH 62/76] Adming vetting list improvements
---
webclient/src/Admin.js | 15 ++++++---------
webclient/src/light.css | 5 +++++
2 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/webclient/src/Admin.js b/webclient/src/Admin.js
index 642722a..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
- NMO
- 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}
- {x.status || 'Unknown'}
+ {x.orientation_date ? '✅' : '❌'}
- {x.orientation_date ? '✅' : '❌'}
- {x.current_start_date}
)}
@@ -344,6 +340,7 @@ export function Admin(props) {
Members who are Current or Due, and past their probationary period.
+ Sorted by last name.
diff --git a/webclient/src/light.css b/webclient/src/light.css
index 323d0bd..5d41edd 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;
From 7e1e9d5f8cca9a53b3cd0897cf48878d3b1514c4 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 9 May 2023 17:46:52 +0000
Subject: [PATCH 63/76] Send reminders to instructors that they are teaching a
class
---
.../api/management/commands/send_reminders.py | 84 +++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 apiserver/apiserver/api/management/commands/send_reminders.py
diff --git a/apiserver/apiserver/api/management/commands/send_reminders.py b/apiserver/apiserver/api/management/commands/send_reminders.py
new file mode 100644
index 0000000..05cb100
--- /dev/null
+++ b/apiserver/apiserver/api/management/commands/send_reminders.py
@@ -0,0 +1,84 @@
+from django.core.management.base import BaseCommand, CommandError
+from django.contrib.auth.models import User
+from django.db.models import Max, F, Count, Q, Sum
+from django.utils.timezone import now
+from django.core.cache import cache
+from django.db import transaction
+from datetime import datetime, timedelta
+import math
+
+from apiserver import secrets, settings
+from apiserver.api import models, utils, utils_email
+
+import time
+
+class Command(BaseCommand):
+ help = 'Send email reminders to instructors that they are teaching a class'
+
+ def send_class_reminders(self):
+ 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 handle(self, *args, **options):
+ self.stdout.write('{} - Class reminder emails'.format(str(now())))
+ start = time.time()
+
+ count = self.send_class_reminders()
+ self.stdout.write('Sent {} reminders'.format(count))
+
+ self.stdout.write('Completed reminders in {} s'.format(
+ str(time.time() - start)[:4]
+ ))
From 8995a8fc98450b81ce9077c092e7dc1b3dc852a9 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 9 May 2023 17:51:09 +0000
Subject: [PATCH 64/76] Move send_reminders command into run_hourly command
---
.../api/management/commands/run_hourly.py | 63 +++++++++++++-
.../api/management/commands/send_reminders.py | 84 -------------------
2 files changed, 62 insertions(+), 85 deletions(-)
delete mode 100644 apiserver/apiserver/api/management/commands/send_reminders.py
diff --git a/apiserver/apiserver/api/management/commands/run_hourly.py b/apiserver/apiserver/api/management/commands/run_hourly.py
index b4a6e2d..31b4fff 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,63 @@ class Command(BaseCommand):
utils.gen_search_strings()
+ def send_class_reminders(self):
+ 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 handle(self, *args, **options):
self.stdout.write('{} - Beginning hourly tasks'.format(str(now())))
@@ -41,6 +99,9 @@ class Command(BaseCommand):
self.generate_stats()
self.stdout.write('Generated stats')
+ count = self.send_class_reminders()
+ self.stdout.write('Sent {} reminders'.format(count))
+
self.stdout.write('Completed tasks in {} s'.format(
str(time.time() - start)[:4]
))
diff --git a/apiserver/apiserver/api/management/commands/send_reminders.py b/apiserver/apiserver/api/management/commands/send_reminders.py
deleted file mode 100644
index 05cb100..0000000
--- a/apiserver/apiserver/api/management/commands/send_reminders.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from django.core.management.base import BaseCommand, CommandError
-from django.contrib.auth.models import User
-from django.db.models import Max, F, Count, Q, Sum
-from django.utils.timezone import now
-from django.core.cache import cache
-from django.db import transaction
-from datetime import datetime, timedelta
-import math
-
-from apiserver import secrets, settings
-from apiserver.api import models, utils, utils_email
-
-import time
-
-class Command(BaseCommand):
- help = 'Send email reminders to instructors that they are teaching a class'
-
- def send_class_reminders(self):
- 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 handle(self, *args, **options):
- self.stdout.write('{} - Class reminder emails'.format(str(now())))
- start = time.time()
-
- count = self.send_class_reminders()
- self.stdout.write('Sent {} reminders'.format(count))
-
- self.stdout.write('Completed reminders in {} s'.format(
- str(time.time() - start)[:4]
- ))
From 7bd1c9f17579b01d3297a8d352ee195106965c34 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 12 May 2023 17:26:52 +0000
Subject: [PATCH 65/76] Test sending reminders for instructors to mark
attendance
---
.../api/management/commands/run_hourly.py | 74 ++++++++++++++++++-
1 file changed, 73 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/management/commands/run_hourly.py b/apiserver/apiserver/api/management/commands/run_hourly.py
index 31b4fff..4778375 100644
--- a/apiserver/apiserver/api/management/commands/run_hourly.py
+++ b/apiserver/apiserver/api/management/commands/run_hourly.py
@@ -35,6 +35,8 @@ 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()
@@ -91,6 +93,73 @@ class Command(BaseCommand):
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())))
@@ -100,7 +169,10 @@ class Command(BaseCommand):
self.stdout.write('Generated stats')
count = self.send_class_reminders()
- self.stdout.write('Sent {} reminders'.format(count))
+ 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]
From 62d122d4142ef7d7ac77aa5c9ce8d4c6393b9d0e Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 12 May 2023 17:27:27 +0000
Subject: [PATCH 66/76] Fix "received a naive datetime" warning
---
apiserver/apiserver/api/serializers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index 1def532..d31b2af 100644
--- a/apiserver/apiserver/api/serializers.py
+++ b/apiserver/apiserver/api/serializers.py
@@ -643,7 +643,7 @@ class CourseDetailSerializer(serializers.ModelSerializer):
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
From b325e648f49924aa548acc4a39aef7b88c27d868 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 15 May 2023 17:19:59 +0000
Subject: [PATCH 67/76] Handle pre-Spaceport yearly PayPal subs
---
apiserver/apiserver/api/utils_paypal.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apiserver/apiserver/api/utils_paypal.py b/apiserver/apiserver/api/utils_paypal.py
index ddd9cf1..f0660b9 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 = ''
From 0f2fad72097819bc265af6f36e1a3e3d32bc8cfc Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 15 May 2023 17:20:24 +0000
Subject: [PATCH 68/76] Decrease LCARS font size to prevent header wrapping
---
webclient/src/light.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webclient/src/light.css b/webclient/src/light.css
index 5d41edd..1dd36d9 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -184,7 +184,7 @@ body {
height: 100vh;
background-color: black;
color: white;
- font-size: 1.8em;
+ font-size: 1.75em;
}
.display-usage {
From a94918a8ed094eea7f07a9487d0eece62513b75c Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Tue, 23 May 2023 03:10:06 +0000
Subject: [PATCH 69/76] Allow self-registration on meetings and cleans
---
apiserver/apiserver/api/views.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 5fbe3f9..76af5a2 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -456,8 +456,9 @@ 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 user == session.instructor:
+ 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.'))
From dddfd06b24a229ed6045193f9459fb65c88c4cc6 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Wed, 24 May 2023 01:30:45 +0000
Subject: [PATCH 70/76] Sort "everyone" by protocoin amount
---
apiserver/apiserver/api/views.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 76af5a2..5a65334 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -136,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 = []
From 8f05ba988414b95353effb5f00117db231272cdf Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 25 May 2023 19:19:12 +0000
Subject: [PATCH 71/76] Add giant margin to bottom of Class Feed
---
webclient/src/Classes.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js
index 22978c7..1518a44 100644
--- a/webclient/src/Classes.js
+++ b/webclient/src/Classes.js
@@ -298,6 +298,8 @@ export function ClassFeed(props) {
:
Loading...
}
+
+
);
};
From d1e4f2ca9d7e72c6f01ba66c99550e59a6b06c69 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 25 May 2023 20:36:10 +0000
Subject: [PATCH 72/76] Add optional query logging
---
apiserver/apiserver/settings.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py
index be9ca5e..6c9dd18 100644
--- a/apiserver/apiserver/settings.py
+++ b/apiserver/apiserver/settings.py
@@ -250,6 +250,11 @@ LOGGING = {
},
},
'loggers': {
+ #'django.db.backends': {
+ # 'handlers': ['console'],
+ # 'level': 'DEBUG',
+ # 'propagate': False,
+ # },
'gunicorn': {
'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO',
From cfbbe2095d8785c2aef5136ba85e67100fb33d96 Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Thu, 25 May 2023 17:53:41 -0600
Subject: [PATCH 73/76] Allow paying for Donations and Consumables with
Protocoin
---
apiserver/apiserver/api/views.py | 67 ++++++++++++++++++++++++++++++++
webclient/src/Paymaster.js | 60 +++++++++++++++++++++++++++-
2 files changed, 125 insertions(+), 2 deletions(-)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 5a65334..c789afd 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1150,6 +1150,71 @@ 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
+
+ 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']:
+ raise exceptions.ValidationError(dict(category='Invalid category.'))
+
+ 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.'))
+
+ 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)
+
+ 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:
@@ -1178,6 +1243,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.'))
@@ -1318,6 +1384,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.'))
diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js
index 63cd02e..f88a0d4 100644
--- a/webclient/src/Paymaster.js
+++ b/webclient/src/Paymaster.js
@@ -6,6 +6,44 @@ 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, setAmount, 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);
+ setAmount('');
+ 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 +114,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;
@@ -188,6 +226,15 @@ export function Paymaster(props) {
name='Protospace Consumables'
custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })}
/>
+
+
+
+
@@ -221,6 +268,15 @@ export function Paymaster(props) {
name='Protospace Donation'
custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })}
/>
+
+
+
+
From c8378374b039ce6ebfa0bbbd991ca5206521fc7f Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Fri, 26 May 2023 14:17:49 -0600
Subject: [PATCH 74/76] Allow paying course fees with Protocoin
---
apiserver/apiserver/api/utils_paypal.py | 2 +-
apiserver/apiserver/api/views.py | 52 ++++++++++++++++++++++---
webclient/src/Classes.js | 13 +++++++
webclient/src/Paymaster.js | 10 +++--
4 files changed, 66 insertions(+), 11 deletions(-)
diff --git a/apiserver/apiserver/api/utils_paypal.py b/apiserver/apiserver/api/utils_paypal.py
index f0660b9..b5078cf 100644
--- a/apiserver/apiserver/api/utils_paypal.py
+++ b/apiserver/apiserver/api/utils_paypal.py
@@ -250,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/views.py b/apiserver/apiserver/api/views.py
index c789afd..766e910 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -1157,6 +1157,8 @@ class ProtocoinViewSet(Base):
source_user = self.request.user
source_member = source_user.member
+ training = None
+
try:
balance = float(request.data['balance'])
except KeyError:
@@ -1175,9 +1177,33 @@ class ProtocoinViewSet(Base):
category = str(request.data['category'])
except KeyError:
raise exceptions.ValidationError(dict(category='This field is required.'))
- if category not in ['Consumables', 'Donation']:
+ 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
@@ -1193,11 +1219,19 @@ class ProtocoinViewSet(Base):
if source_user_balance < amount:
raise exceptions.ValidationError(dict(amount='Insufficient funds.'))
- tx_memo = 'Protocoin - Transaction spent ₱ {} on {}{}'.format(
- amount,
- category,
- ', memo: ' + memo if memo else ''
- )
+ 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,
@@ -1211,6 +1245,12 @@ class ProtocoinViewSet(Base):
)
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)
diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js
index 1518a44..8e48b04 100644
--- a/webclient/src/Classes.js
+++ b/webclient/src/Classes.js
@@ -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) {
@@ -693,6 +694,18 @@ export function ClassDetail(props) {
name={clazz.course_data.name}
custom={JSON.stringify({ training: userTraining.id })}
/>
+
+
+
+ {
+ refreshUser();
+ refreshClass();
+ }}
+ custom={{ category: 'OnAcct', training: userTraining.id }}
+ />
}
diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js
index f88a0d4..0a8508f 100644
--- a/webclient/src/Paymaster.js
+++ b/webclient/src/Paymaster.js
@@ -7,7 +7,7 @@ import { MembersDropdown } from './Members.js';
import { requester } from './utils.js';
export function PayWithProtocoin(props) {
- const { token, user, refreshUser, amount, setAmount, custom } = props;
+ const { token, user, refreshUser, amount, onSuccess, custom } = props;
const member = user.member;
const [error, setError] = useState({});
const [loading, setLoading] = useState(false);
@@ -23,7 +23,9 @@ export function PayWithProtocoin(props) {
.then(res => {
setLoading(false);
setSuccess(true);
- setAmount('');
+ if (onSuccess) {
+ onSuccess();
+ }
setError({});
refreshUser();
})
@@ -232,7 +234,7 @@ export function Paymaster(props) {
setConsumables('')}
custom={{ category: 'Consumables', memo: consumablesMemo }}
/>
@@ -274,7 +276,7 @@ export function Paymaster(props) {
setDonate('')}
custom={{ category: 'Donation', memo: memo }}
/>
From cd7af1ac5cd098fdde4633634b7d6d7799946fee Mon Sep 17 00:00:00 2001
From: Tanner Collin
Date: Mon, 29 May 2023 11:33:53 -0600
Subject: [PATCH 75/76] Add current protocoin balance to class payment
---
webclient/src/Classes.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js
index 8e48b04..aa963d0 100644
--- a/webclient/src/Classes.js
+++ b/webclient/src/Classes.js
@@ -697,6 +697,8 @@ export function ClassDetail(props) {
+ Current balance: ₱ {user.member.protocoin.toFixed(2)}
+
Date: Mon, 29 May 2023 11:43:32 -0600
Subject: [PATCH 76/76] Make Paymaster's Consumables and Donation inline
---
webclient/src/Paymaster.js | 19 +++++++++----------
1 file changed, 9 insertions(+), 10 deletions(-)
diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js
index 0a8508f..2b9dbbc 100644
--- a/webclient/src/Paymaster.js
+++ b/webclient/src/Paymaster.js
@@ -196,12 +196,12 @@ export function Paymaster(props) {
-
-
- Pay for materials you use (ie. welding gas, 3D printing, blades, etc).
-
-
+
+
+
+ Pay for materials you use (ie. welding gas, 3D printing, etc).
+
Custom amount:
@@ -238,12 +238,11 @@ export function Paymaster(props) {
custom={{ category: 'Consumables', memo: consumablesMemo }}
/>
-
-
-
-
-
+
+
+ Donation of any amount to Protospace.
+
Custom amount: