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 &&
Quick Links
-

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

- Waiting for job -

- +

+ Waiting for job +

} ); }; + +export function DisplayScores(props) { + const { token, name } = props; + const [scores, setScores] = useState(false); + + const getScores = () => { + requester('/pinball/high_scores/', 'GET') + .then(res => { + setScores(res); + }) + .catch(err => { + console.log(err); + setScores(false); + }); + }; + + useEffect(() => { + getScores(); + const interval = setInterval(getScores, 60000); + return () => clearInterval(interval); + }, []); + + return ( + <> +
Pinball High Scores
+ + {scores && scores.map((x, i) => +
+
#{i+1} — {x.name}.
+

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

{x.hours.toFixed(2)} hours

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

{x.score.toLocaleString()}

+
+ )} + + + ); +}; + export function DisplayHosting(props) { const { token, name } = props; const [scores, setScores] = useState(false); 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 ( -
-
Report Transaction
- -

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

-

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

-

Follow up with directors@protospace.ca.

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

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

+

directors@protospace.ca

+

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

} 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 (
@@ -150,12 +161,20 @@ export function AdminHistoricalTransactions(props) { } - !excludePayPal || x.account_type !== 'PayPal')} /> + + +
:

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) { Latest Training - {unpaidTraining.map(x => Please pay your course fee! @@ -103,6 +101,12 @@ function MemberInfo(props) { )} +
Latest Training
+ + {!member.orientation_date &&

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

} + {lastTrain.length ? @@ -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) {
Ready to Vet

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) { -
Consumables
- -

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

- - + +
Consumables
+ +

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

+ Custom amount:
@@ -238,12 +238,11 @@ export function Paymaster(props) { custom={{ category: 'Consumables', memo: consumablesMemo }} /> - - -
Donate
- - +
Donate
+ +

Donation of any amount to Protospace.

+ Custom amount: