Merge branch 'master' into storage_space
This commit is contained in:
@@ -12,9 +12,10 @@ for model in app_models:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(model, 'MY_FIELDS'):
|
||||
MyAdmin.list_display = model.MY_FIELDS
|
||||
MyAdmin.search_fields = model.MY_FIELDS
|
||||
if hasattr(model, 'list_display'):
|
||||
MyAdmin.list_display = model.list_display
|
||||
if hasattr(model, 'search_fields'):
|
||||
MyAdmin.search_fields = model.search_fields
|
||||
|
||||
admin.site.register(model, MyAdmin)
|
||||
except AlreadyRegistered:
|
||||
|
26
apiserver/apiserver/api/emails/overdue.html
Normal file
26
apiserver/apiserver/api/emails/overdue.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style type="text/css">p.MsoNormal,p.MsoNoSpacing{margin:0}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>Hi [name],<br></div>
|
||||
<div><br></div>
|
||||
<div>Your Protospace member dues are behind by two months and you are now "overdue".<br></div>
|
||||
<div><br></div>
|
||||
<div>You are paid up until [date]. Please pay your dues to prevent having your card and account deactivated by the system.<br></div>
|
||||
<div><br></div>
|
||||
<div>You can log into the portal and pay here:<br></div>
|
||||
<div><a href="https://my.protospace.ca/paymaster">https://my.protospace.ca/paymaster</a><br></div>
|
||||
<div><br></div>
|
||||
<div>Or send e-Transfer to info@protospace.ca or hand a director cash.<br></div>
|
||||
<div><br></div>
|
||||
<div>If there has been an error or you want to reply to this email, please click "reply-all" since the Spaceport inbox does not exist.<br></div>
|
||||
<div><br></div>
|
||||
<div>You won't recieve any other emails about this.<br></div>
|
||||
<div><br></div>
|
||||
<div>Thanks,</div>
|
||||
<div>Spaceport<br></div>
|
||||
</body>
|
||||
</html>
|
19
apiserver/apiserver/api/emails/overdue.txt
Normal file
19
apiserver/apiserver/api/emails/overdue.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Hi [name],
|
||||
|
||||
Your Protospace member dues are behind by two months and you are now "overdue".
|
||||
|
||||
You are paid up until [date]. Please pay your dues to prevent having your
|
||||
account and card deactivated by the system.
|
||||
|
||||
You can log into the portal and pay here:
|
||||
https://my.protospace.ca/paymaster
|
||||
|
||||
Or send e-Transfer to info@protospace.ca or hand a director cash.
|
||||
|
||||
If there has been an error or you want to reply to this email, please click
|
||||
"reply-all" since the Spaceport inbox does not exist.
|
||||
|
||||
You won't recieve any other emails about this.
|
||||
|
||||
Thanks,
|
||||
Spaceport
|
@@ -1,6 +1,7 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.timezone import now
|
||||
from apiserver.api import models, utils, utils_stats
|
||||
from apiserver.api import models, utils, utils_stats, utils_email
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import time
|
||||
|
||||
@@ -33,6 +34,132 @@ class Command(BaseCommand):
|
||||
|
||||
utils.gen_search_strings()
|
||||
|
||||
def send_class_reminders(self):
|
||||
# sends reminders to instructors that they are teaching a class
|
||||
# within 6-7 hours from now
|
||||
count = 0
|
||||
|
||||
now = utils.now_alberta_tz()
|
||||
current_hour_start = now.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
in_six_hours = current_hour_start + timedelta(hours=6)
|
||||
in_seven_hours = current_hour_start + timedelta(hours=7)
|
||||
|
||||
sessions = models.Session.objects.all()
|
||||
reminder_sessions = sessions.filter(
|
||||
datetime__gte=in_six_hours,
|
||||
datetime__lt=in_seven_hours,
|
||||
)
|
||||
|
||||
if reminder_sessions.count() == 0:
|
||||
self.stdout.write('No classes found within timeframe, returning')
|
||||
return 0
|
||||
|
||||
self.stdout.write('Found {} reminder sessions between {} and {} mountain time.'.format(
|
||||
reminder_sessions.count(),
|
||||
str(in_six_hours),
|
||||
str(in_seven_hours),
|
||||
))
|
||||
|
||||
for session in reminder_sessions:
|
||||
self.stdout.write('Session {} instructor {}:'.format(
|
||||
str(session),
|
||||
session.instructor.username,
|
||||
))
|
||||
|
||||
if session.is_cancelled:
|
||||
self.stdout.write(' Is cancelled, skipping.')
|
||||
continue
|
||||
|
||||
if session.course.id in [317, 273, 413]:
|
||||
self.stdout.write(' Is members meeting or cleanup, skipping.')
|
||||
continue
|
||||
|
||||
if session.course.tags in ['Event', 'Outing']:
|
||||
self.stdout.write(' Is only outing or event, skipping.')
|
||||
continue
|
||||
|
||||
self.stdout.write(' Emailing {} {}:'.format(session.instructor.username, session.instructor.email))
|
||||
|
||||
utils.alert_tanner('Class reminder {} for {} {}'.format(
|
||||
str(session),
|
||||
session.instructor.username,
|
||||
session.instructor.email,
|
||||
))
|
||||
|
||||
self.stdout.write(' Sent class reminder email.')
|
||||
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def send_attendance_reminders(self):
|
||||
# sends reminders to instructors to mark attendance for classes
|
||||
# that happened 6-7 hours ago if they haven't already
|
||||
count = 0
|
||||
|
||||
now = utils.now_alberta_tz()
|
||||
current_hour_start = now.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
six_hours_ago = current_hour_start - timedelta(hours=6)
|
||||
seven_hours_ago = current_hour_start - timedelta(hours=7)
|
||||
|
||||
sessions = models.Session.objects.all()
|
||||
reminder_sessions = sessions.filter(
|
||||
datetime__gte=seven_hours_ago,
|
||||
datetime__lt=six_hours_ago,
|
||||
)
|
||||
|
||||
if reminder_sessions.count() == 0:
|
||||
self.stdout.write('No classes found within timeframe, returning')
|
||||
return 0
|
||||
|
||||
self.stdout.write('Found {} sessions between {} and {} mountain time.'.format(
|
||||
reminder_sessions.count(),
|
||||
str(seven_hours_ago),
|
||||
str(six_hours_ago),
|
||||
))
|
||||
|
||||
for session in reminder_sessions:
|
||||
self.stdout.write('Session {} instructor {}:'.format(
|
||||
str(session),
|
||||
session.instructor.username,
|
||||
))
|
||||
|
||||
if session.is_cancelled:
|
||||
self.stdout.write(' Is cancelled, skipping.')
|
||||
continue
|
||||
|
||||
if session.course.id in [317, 273, 413]:
|
||||
self.stdout.write(' Is members meeting or cleanup, skipping.')
|
||||
continue
|
||||
|
||||
if session.course.tags in ['Event', 'Outing']:
|
||||
self.stdout.write(' Is only outing or event, skipping.')
|
||||
continue
|
||||
|
||||
if session.students.count() == 0:
|
||||
self.stdout.write(' Class is empty, skipping.')
|
||||
continue
|
||||
|
||||
if session.students.filter(attendance_status='Attended').count() > 0:
|
||||
self.stdout.write(' Instructor already marked attendance, skipping.')
|
||||
continue
|
||||
|
||||
self.stdout.write(' Emailing {} {}:'.format(session.instructor.username, session.instructor.email))
|
||||
|
||||
utils.alert_tanner('Attendance reminder {} for {} {}'.format(
|
||||
str(session),
|
||||
session.instructor.username,
|
||||
session.instructor.email,
|
||||
))
|
||||
|
||||
self.stdout.write(' Sent attendance reminder email.')
|
||||
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('{} - Beginning hourly tasks'.format(str(now())))
|
||||
@@ -41,6 +168,12 @@ class Command(BaseCommand):
|
||||
self.generate_stats()
|
||||
self.stdout.write('Generated stats')
|
||||
|
||||
count = self.send_class_reminders()
|
||||
self.stdout.write('Sent {} class reminders'.format(count))
|
||||
|
||||
count = self.send_attendance_reminders()
|
||||
self.stdout.write('Sent {} attendance reminders'.format(count))
|
||||
|
||||
self.stdout.write('Completed tasks in {} s'.format(
|
||||
str(time.time() - start)[:4]
|
||||
))
|
||||
|
@@ -7,12 +7,14 @@ from django.utils.timezone import now, pytz
|
||||
from simple_history.models import HistoricalRecords
|
||||
from simple_history import register
|
||||
|
||||
TIMEZONE_CALGARY = pytz.timezone('America/Edmonton')
|
||||
|
||||
register(User)
|
||||
|
||||
IGNORE = '+'
|
||||
|
||||
def today_alberta_tz():
|
||||
return datetime.now(pytz.timezone('America/Edmonton')).date()
|
||||
return datetime.now(TIMEZONE_CALGARY).date()
|
||||
|
||||
class Member(models.Model):
|
||||
user = models.OneToOneField(User, related_name='member', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
@@ -61,9 +63,10 @@ class Member(models.Model):
|
||||
|
||||
history = HistoricalRecords(excluded_fields=['member_forms'])
|
||||
|
||||
MY_FIELDS = ['user', 'preferred_name', 'last_name', 'status']
|
||||
list_display = ['user', 'preferred_name', 'last_name', 'status']
|
||||
search_fields = ['user__username', 'preferred_name', 'last_name', 'status']
|
||||
def __str__(self):
|
||||
return self.user.username
|
||||
return getattr(self.user, 'username', 'None')
|
||||
|
||||
class Transaction(models.Model):
|
||||
user = models.ForeignKey(User, related_name='transactions', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
@@ -72,7 +75,7 @@ class Transaction(models.Model):
|
||||
member_id = models.IntegerField(blank=True, null=True)
|
||||
date = models.DateField(default=today_alberta_tz)
|
||||
amount = models.DecimalField(max_digits=7, decimal_places=2)
|
||||
reference_number = models.CharField(max_length=32, blank=True, null=True)
|
||||
reference_number = models.CharField(max_length=64, blank=True, null=True)
|
||||
memo = models.TextField(blank=True, null=True)
|
||||
number_of_membership_months = models.IntegerField(blank=True, null=True)
|
||||
payment_method = models.TextField(blank=True, null=True)
|
||||
@@ -89,7 +92,8 @@ class Transaction(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['date', 'user', 'amount', 'protocoin', 'account_type', 'category']
|
||||
list_display = ['date', 'user', 'amount', 'protocoin', 'account_type', 'category']
|
||||
search_fields = ['date', 'user__username', 'account_type', 'category']
|
||||
def __str__(self):
|
||||
return '%s tx %s' % (user.username, date)
|
||||
|
||||
@@ -101,7 +105,8 @@ class PayPalHint(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['account', 'user']
|
||||
list_display = ['account', 'user']
|
||||
search_fields = ['account', 'user__username']
|
||||
def __str__(self):
|
||||
return self.account
|
||||
|
||||
@@ -112,7 +117,8 @@ class IPN(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['datetime', 'status']
|
||||
list_display = ['datetime', 'status']
|
||||
search_fields = ['datetime', 'status']
|
||||
def __str__(self):
|
||||
return self.datetime
|
||||
|
||||
@@ -128,7 +134,8 @@ class Card(models.Model):
|
||||
|
||||
history = HistoricalRecords(excluded_fields=['last_seen_at', 'last_seen'])
|
||||
|
||||
MY_FIELDS = ['card_number', 'user', 'last_seen']
|
||||
list_display = ['card_number', 'user', 'last_seen']
|
||||
search_fields = ['card_number', 'user__username', 'last_seen']
|
||||
def __str__(self):
|
||||
return self.card_number
|
||||
|
||||
@@ -140,7 +147,8 @@ class Course(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['name', 'id']
|
||||
list_display = ['name', 'id']
|
||||
search_fields = ['name', 'id']
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -156,9 +164,10 @@ class Session(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['datetime', 'course', 'instructor']
|
||||
list_display = ['datetime', 'course', 'instructor']
|
||||
search_fields = ['datetime', 'course__name', 'instructor__username']
|
||||
def __str__(self):
|
||||
return '%s @ %s' % (self.course.name, self.datetime)
|
||||
return '%s @ %s' % (self.course.name, self.datetime.astimezone(TIMEZONE_CALGARY).strftime('%Y-%m-%d %-I:%M %p'))
|
||||
|
||||
class Training(models.Model):
|
||||
user = models.ForeignKey(User, related_name='training', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
@@ -171,7 +180,8 @@ class Training(models.Model):
|
||||
|
||||
history = HistoricalRecords()
|
||||
|
||||
MY_FIELDS = ['session', 'user']
|
||||
list_display = ['session', 'user']
|
||||
search_fields = ['session__course__name', 'user__username']
|
||||
def __str__(self):
|
||||
return '%s taking %s @ %s' % (self.user, self.session.course.name, self.session.datetime)
|
||||
|
||||
@@ -181,7 +191,8 @@ class Interest(models.Model):
|
||||
|
||||
satisfied_by = models.ForeignKey(Session, related_name='satisfies', null=True, on_delete=models.SET_NULL)
|
||||
|
||||
MY_FIELDS = ['user', 'course', 'satisfied_by']
|
||||
list_display = ['user', 'course', 'satisfied_by']
|
||||
search_fields = ['user__username', 'course__name']
|
||||
def __str__(self):
|
||||
return '%s interested in %s' % (self.user, self.course)
|
||||
|
||||
@@ -197,7 +208,8 @@ class StatsMemberCount(models.Model):
|
||||
vetted_count = models.IntegerField()
|
||||
subscriber_count = models.IntegerField()
|
||||
|
||||
MY_FIELDS = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count']
|
||||
list_display = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count']
|
||||
search_fields = ['date', 'member_count', 'green_count', 'six_month_plus_count', 'vetted_count', 'subscriber_count']
|
||||
|
||||
class StatsSignupCount(models.Model):
|
||||
month = models.DateField()
|
||||
@@ -205,13 +217,15 @@ class StatsSignupCount(models.Model):
|
||||
retain_count = models.IntegerField(default=0)
|
||||
vetted_count = models.IntegerField(default=0)
|
||||
|
||||
MY_FIELDS = ['month', 'signup_count', 'retain_count', 'vetted_count']
|
||||
list_display = ['month', 'signup_count', 'retain_count', 'vetted_count']
|
||||
search_fields = ['month', 'signup_count', 'retain_count', 'vetted_count']
|
||||
|
||||
class StatsSpaceActivity(models.Model):
|
||||
date = models.DateField(default=today_alberta_tz)
|
||||
card_scans = models.IntegerField()
|
||||
|
||||
MY_FIELDS = ['date', 'card_scans']
|
||||
list_display = ['date', 'card_scans']
|
||||
search_fields = ['date', 'card_scans']
|
||||
|
||||
class Usage(models.Model):
|
||||
user = models.ForeignKey(User, related_name='usages', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
@@ -221,7 +235,7 @@ class Usage(models.Model):
|
||||
device = models.CharField(max_length=64)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField(null=True)
|
||||
deleted_at = models.DateTimeField(null=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
num_seconds = models.IntegerField()
|
||||
num_reports = models.IntegerField()
|
||||
@@ -230,9 +244,10 @@ class Usage(models.Model):
|
||||
|
||||
history = HistoricalRecords(excluded_fields=['num_reports'])
|
||||
|
||||
MY_FIELDS = ['started_at', 'finished_at', 'user', 'num_seconds', 'should_bill']
|
||||
list_display = ['started_at', 'finished_at', 'user', 'num_seconds', 'should_bill']
|
||||
search_fields = ['started_at', 'finished_at', 'user__username']
|
||||
def __str__(self):
|
||||
return self.started_at
|
||||
return str(self.started_at)
|
||||
|
||||
class PinballScore(models.Model):
|
||||
user = models.ForeignKey(User, related_name='scores', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
@@ -246,7 +261,22 @@ class PinballScore(models.Model):
|
||||
|
||||
# no history
|
||||
|
||||
MY_FIELDS = ['started_at', 'game_id', 'player', 'score', 'user']
|
||||
list_display = ['started_at', 'game_id', 'player', 'score', 'user']
|
||||
search_fields = ['started_at', 'game_id', 'player', 'score', 'user__username']
|
||||
def __str__(self):
|
||||
return str(self.started_at)
|
||||
|
||||
class Hosting(models.Model):
|
||||
user = models.ForeignKey(User, related_name='hosting', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField()
|
||||
hours = models.DecimalField(max_digits=5, decimal_places=2)
|
||||
|
||||
# no history
|
||||
|
||||
list_display = ['started_at', 'hours', 'finished_at', 'user']
|
||||
search_fields = ['started_at', 'hours', 'finished_at', 'user__username']
|
||||
def __str__(self):
|
||||
return str(self.started_at)
|
||||
|
||||
@@ -284,7 +314,8 @@ class HistoryIndex(models.Model):
|
||||
is_system = models.BooleanField()
|
||||
is_admin = models.BooleanField()
|
||||
|
||||
MY_FIELDS = ['history_date', 'history_user', 'history_type', 'owner_name', 'object_name']
|
||||
list_display = ['history_date', 'history_user', 'history_type', 'owner_name', 'object_name']
|
||||
search_fields = ['history_date', 'history_user__username', 'history_type', 'owner_name', 'object_name']
|
||||
def __str__(self):
|
||||
return '%s changed %s\'s %s' % (self.history_user, self.owner_name, self.object_name)
|
||||
|
||||
@@ -295,6 +326,7 @@ class HistoryChange(models.Model):
|
||||
old = models.TextField()
|
||||
new = models.TextField()
|
||||
|
||||
MY_FIELDS = ['field', 'old', 'new', 'index']
|
||||
list_display = ['field', 'old', 'new', 'index']
|
||||
search_fields = ['field', 'old', 'new', 'index__history_user__username']
|
||||
def __str__(self):
|
||||
return self.field
|
||||
|
@@ -158,7 +158,8 @@ class TransactionSerializer(serializers.ModelSerializer):
|
||||
current_protocoin = (user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0) - instance.protocoin
|
||||
new_protocoin = current_protocoin + validated_data['protocoin']
|
||||
if new_protocoin < 0:
|
||||
raise ValidationError(dict(category='Insufficient funds. Member only had {} protocoin.'.format(current_protocoin)))
|
||||
msg = 'Negative Protocoin transaction updated:\n' + str(validated_data)
|
||||
utils.alert_tanner(msg)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -633,10 +634,23 @@ class SessionSerializer(serializers.ModelSerializer):
|
||||
else:
|
||||
return None
|
||||
|
||||
def create(self, validated_data):
|
||||
if validated_data['datetime'] < now() - datetime.timedelta(days=2):
|
||||
msg = 'Past class creation detected:\n' + str(validated_data)
|
||||
utils.alert_tanner(msg)
|
||||
raise ValidationError(dict(non_field_errors='Class can\'t be in the past.'))
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if not self.initial_data.get('instructor_id', None):
|
||||
raise ValidationError(dict(instructor_id='This field is required.'))
|
||||
|
||||
if validated_data['datetime'] < now() - datetime.timedelta(days=2):
|
||||
msg = 'Past class modification detected:\n' + str(validated_data)
|
||||
utils.alert_tanner(msg)
|
||||
raise ValidationError(dict(non_field_errors='Can\'t modify past class.'))
|
||||
|
||||
member = get_object_or_404(models.Member, id=self.initial_data['instructor_id'])
|
||||
if not (is_admin_director(member.user) or member.is_instructor):
|
||||
raise ValidationError(dict(instructor_id='Member is not an instructor.'))
|
||||
@@ -672,14 +686,15 @@ class CourseDetailSerializer(serializers.ModelSerializer):
|
||||
continue
|
||||
yield date
|
||||
|
||||
def next_date(weekday, week_num=False):
|
||||
def next_date(weekday, week_num=False, fake_start=False):
|
||||
start = fake_start or utils.today_alberta_tz()
|
||||
for date in iter_matching_dates(weekday, week_num):
|
||||
if date > utils.today_alberta_tz():
|
||||
if date > start:
|
||||
return date
|
||||
raise
|
||||
|
||||
def course_is_usually_monthly(course):
|
||||
two_months_ago = utils.today_alberta_tz() - datetime.timedelta(days=61)
|
||||
two_months_ago = utils.now_alberta_tz() - datetime.timedelta(days=61)
|
||||
recent_sessions = obj.sessions.filter(datetime__gte=two_months_ago)
|
||||
if recent_sessions.count() < 3:
|
||||
return True
|
||||
@@ -695,9 +710,14 @@ class CourseDetailSerializer(serializers.ModelSerializer):
|
||||
dt = utils.TIMEZONE_CALGARY.localize(dt)
|
||||
cost = 0
|
||||
max_students = None
|
||||
elif obj.id == 317: # members' meeting 7:00 PM 3rd Thursday of odd months, Wednesday of even months
|
||||
elif obj.id == 317:
|
||||
# members' meeting 7:00 PM 3rd Thursday of odd months, Wednesday of even months
|
||||
# but December's gets skipped
|
||||
next_month = next_date(calendar.WEDNESDAY, week_num=3).month
|
||||
if next_month % 2 == 0:
|
||||
if next_month == 12:
|
||||
one_month_ahead = utils.today_alberta_tz() + datetime.timedelta(days=31)
|
||||
date = next_date(calendar.THURSDAY, week_num=3, fake_start=one_month_ahead)
|
||||
elif next_month % 2 == 0:
|
||||
date = next_date(calendar.WEDNESDAY, week_num=3)
|
||||
else:
|
||||
date = next_date(calendar.THURSDAY, week_num=3)
|
||||
@@ -745,6 +765,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
training = UserTrainingSerializer(many=True)
|
||||
member = MemberSerializer()
|
||||
transactions = serializers.SerializerMethodField()
|
||||
training = serializers.SerializerMethodField()
|
||||
interests = InterestSerializer(many=True)
|
||||
door_code = serializers.SerializerMethodField()
|
||||
wifi_pass = serializers.SerializerMethodField()
|
||||
@@ -771,12 +792,27 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_transactions(self, obj):
|
||||
queryset = models.Transaction.objects.filter(user=obj)
|
||||
queryset = queryset.select_related('user', 'user__member')
|
||||
queryset = queryset.exclude(category='Memberships:Fake Months')
|
||||
queryset = queryset.order_by('-id', '-date')
|
||||
serializer = TransactionSerializer(data=queryset, many=True)
|
||||
serializer.is_valid()
|
||||
return serializer.data
|
||||
|
||||
def get_training(self, obj):
|
||||
queryset = obj.training
|
||||
queryset = queryset.select_related(
|
||||
'session',
|
||||
'session__course',
|
||||
'session__instructor',
|
||||
'session__instructor__member'
|
||||
)
|
||||
queryset = queryset.prefetch_related('session__students')
|
||||
queryset = queryset.order_by('-id')
|
||||
serializer = UserTrainingSerializer(data=queryset, many=True)
|
||||
serializer.is_valid()
|
||||
return serializer.data
|
||||
|
||||
def get_door_code(self, obj):
|
||||
if not obj.member.paused_date and obj.cards.count():
|
||||
return secrets.DOOR_CODE
|
||||
|
@@ -19,11 +19,20 @@ class LoggingThrottle(throttling.BaseThrottle):
|
||||
if path.startswith('/lockout/'):
|
||||
return True
|
||||
elif path == '/stats/sign/':
|
||||
pass
|
||||
pass # log this one
|
||||
elif path.startswith('/stats/'):
|
||||
return True
|
||||
elif path == '/sessions/' and user == None:
|
||||
return True
|
||||
elif path in [
|
||||
'/pinball/high_scores/',
|
||||
'/pinball/monthly_high_scores/',
|
||||
'/protocoin/printer_balance/',
|
||||
'/hosting/high_scores/',
|
||||
'/stats/ord2/printer3d/',
|
||||
'/stats/ord3/printer3d/'
|
||||
]:
|
||||
return True
|
||||
|
||||
if request.data:
|
||||
if type(request.data) is not dict:
|
||||
|
@@ -70,7 +70,7 @@ def calc_member_status(expire_date, fake_date=None):
|
||||
if today + timedelta(days=29) < expire_date:
|
||||
return 'Prepaid'
|
||||
elif difference <= -3:
|
||||
return 'Former Member'
|
||||
return 'Expired Member'
|
||||
elif today - timedelta(days=29) >= expire_date:
|
||||
return 'Overdue'
|
||||
elif today < expire_date:
|
||||
@@ -104,11 +104,26 @@ def tally_membership_months(member, fake_date=None):
|
||||
status = calc_member_status(expire_date, fake_date)
|
||||
|
||||
if member.expire_date != expire_date or member.status != status:
|
||||
previous_status = member.status
|
||||
|
||||
member.expire_date = expire_date
|
||||
member.status = status
|
||||
|
||||
if status == 'Former Member':
|
||||
member.paused_date = expire_date
|
||||
if status == 'Expired Member':
|
||||
member.paused_date = today_alberta_tz()
|
||||
msg = 'Member has expired: {} {}'.format(member.preferred_name, member.last_name)
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
|
||||
if status == 'Overdue':
|
||||
if previous_status == 'Due':
|
||||
msg = 'Member has become Overdue: {} {}'.format(member.preferred_name, member.last_name)
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
|
||||
utils_email.send_overdue_email(member)
|
||||
else:
|
||||
logger.info('Skipping email because member wasn\'t due before.')
|
||||
|
||||
member.save()
|
||||
logging.debug('Tallied %s membership months: updated.', member)
|
||||
@@ -466,7 +481,10 @@ def gen_member_forms(member):
|
||||
def custom_exception_handler(exc, context):
|
||||
response = exception_handler(exc, context)
|
||||
if response is not None:
|
||||
logging.warning('Response: %s', json.dumps(exc.detail))
|
||||
if hasattr(exc, 'detail'):
|
||||
logging.warning('Response: %s', json.dumps(exc.detail))
|
||||
else:
|
||||
logging.warning('Response: %s', exc)
|
||||
return response
|
||||
|
||||
def log_transaction(tx):
|
||||
|
@@ -121,10 +121,34 @@ def send_usage_bill_email(user, device, month, minutes, overage, bill):
|
||||
subject='{} {} Usage Bill'.format(month, device),
|
||||
message=email_text,
|
||||
from_email=None, # defaults to DEFAULT_FROM_EMAIL
|
||||
recipient_list=[user.email, 'directors@protospace.ca', 'protospace@tannercollin.com'],
|
||||
recipient_list=[user.email, 'directors@protospace.ca', 'spaceport@tannercollin.com'],
|
||||
)
|
||||
|
||||
if not settings.EMAIL_HOST:
|
||||
time.sleep(0.5) # simulate slowly sending emails when logging to console
|
||||
|
||||
logger.info('Sent usage bill email:\n' + email_text)
|
||||
|
||||
def send_overdue_email(member):
|
||||
def replace_fields(text):
|
||||
return text.replace(
|
||||
'[name]', member.preferred_name,
|
||||
).replace(
|
||||
'[date]', member.expire_date.strftime('%B %d, %Y'),
|
||||
)
|
||||
|
||||
with open(EMAIL_DIR + 'overdue.txt', 'r') as f:
|
||||
email_text = replace_fields(f.read())
|
||||
|
||||
with open(EMAIL_DIR + 'overdue.html', 'r') as f:
|
||||
email_html = replace_fields(f.read())
|
||||
|
||||
send_mail(
|
||||
subject='Protospace member dues overdue',
|
||||
message=email_text,
|
||||
from_email=None, # defaults to DEFAULT_FROM_EMAIL
|
||||
recipient_list=[member.user.email, 'directors@protospace.ca', 'spaceport@tannercollin.com'],
|
||||
html_message=email_html,
|
||||
)
|
||||
|
||||
logger.info('Sent overdue email:\n' + email_text)
|
||||
|
@@ -156,6 +156,9 @@ def create_member_dues_tx(data, member, num_months, deal):
|
||||
elif deal == 3 and num_months == 2:
|
||||
num_months = 3
|
||||
deal_str = '3 for 2, '
|
||||
elif num_months == 11: # handle pre-Spaceport yearly subs
|
||||
num_months = 12
|
||||
deal_str = '12 for 11 (legacy), '
|
||||
else:
|
||||
deal_str = ''
|
||||
|
||||
@@ -247,7 +250,7 @@ def check_training(data, training_id, amount):
|
||||
|
||||
if training.attendance_status == 'Waiting for payment':
|
||||
training.attendance_status = 'Confirmed'
|
||||
training.paid_date = datetime.date.today()
|
||||
training.paid_date = utils.today_alberta_tz()
|
||||
training.save()
|
||||
|
||||
logger.info('IPN - Amount valid for training cost, id: ' + str(training.id))
|
||||
|
@@ -28,6 +28,9 @@ DEFAULTS = {
|
||||
'sign': '',
|
||||
'link': '',
|
||||
'autoscan': '',
|
||||
'last_scan': {},
|
||||
'closing': {},
|
||||
'printer3d': {},
|
||||
}
|
||||
|
||||
if secrets.MUMBLE:
|
||||
|
@@ -23,8 +23,7 @@ import icalendar
|
||||
import datetime, time
|
||||
import io
|
||||
import csv
|
||||
|
||||
import requests
|
||||
import xmltodict
|
||||
|
||||
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap, utils_email
|
||||
from .permissions import (
|
||||
@@ -137,7 +136,10 @@ class SearchViewSet(Base, Retrieve):
|
||||
pinball_score=Max('user__scores__score'),
|
||||
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
|
||||
elif sort == 'everyone':
|
||||
queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count', 'id')
|
||||
queryset = queryset.annotate(
|
||||
protocoin_sum=Sum('user__transactions__protocoin'),
|
||||
tx_sum=Sum('user__transactions__amount'),
|
||||
).order_by('-protocoin_sum', '-tx_sum', 'id')
|
||||
elif sort == 'best_looking':
|
||||
queryset = []
|
||||
|
||||
@@ -187,20 +189,42 @@ class MemberViewSet(Base, Retrieve, Update):
|
||||
if not is_admin_director(self.request.user):
|
||||
raise exceptions.PermissionDenied()
|
||||
member = self.get_object()
|
||||
member.status = 'Former Member'
|
||||
member.status = 'Paused Member'
|
||||
member.paused_date = utils.today_alberta_tz()
|
||||
member.save()
|
||||
|
||||
msg = 'Member has been paused: {} {}'.format(member.preferred_name, member.last_name)
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
return Response(200)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def unpause(self, request, pk=None):
|
||||
if not is_admin_director(self.request.user):
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
today = utils.today_alberta_tz()
|
||||
member = self.get_object()
|
||||
member.current_start_date = utils.today_alberta_tz()
|
||||
|
||||
difference = utils.today_alberta_tz() - member.paused_date
|
||||
if difference.days > 370: # give some leeway
|
||||
logging.info('Member has been away for %s days (since %s), unvetting...', difference.days, member.paused_date)
|
||||
member.vetted_date = None
|
||||
member.orientation_date = None
|
||||
member.lathe_cert_date = None
|
||||
member.mill_cert_date = None
|
||||
member.wood_cert_date = None
|
||||
member.wood2_cert_date = None
|
||||
member.tormach_cnc_cert_date = None
|
||||
member.precix_cnc_cert_date = None
|
||||
member.rabbit_cert_date = None
|
||||
member.trotec_cert_date = None
|
||||
|
||||
member.current_start_date = today
|
||||
member.paused_date = None
|
||||
if not member.monthly_fees:
|
||||
member.monthly_fees = 55
|
||||
|
||||
member.save()
|
||||
utils.tally_membership_months(member)
|
||||
utils.gen_member_forms(member)
|
||||
@@ -296,7 +320,7 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
|
||||
course=session.course,
|
||||
satisfied_by__isnull=True,
|
||||
user__member__paused_date__isnull=True
|
||||
)
|
||||
)[:20]
|
||||
|
||||
for num, interest in enumerate(interests):
|
||||
msg = 'Sending email {} / {}...'.format(num+1, len(interests))
|
||||
@@ -309,7 +333,9 @@ class SessionViewSet(Base, List, Retrieve, Create, Update):
|
||||
logger.exception(msg)
|
||||
utils.alert_tanner(msg)
|
||||
|
||||
num_satisfied = interests.update(satisfied_by=session)
|
||||
interest_ids = interests.values('id')
|
||||
num_satisfied = models.Interest.objects.filter(id__in=interest_ids).update(satisfied_by=session)
|
||||
|
||||
logging.info('Satisfied %s interests.', num_satisfied)
|
||||
|
||||
def generate_ical(self, session):
|
||||
@@ -433,6 +459,12 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
||||
|
||||
member = get_object_or_404(models.Member, id=data['member_id'])
|
||||
user = member.user
|
||||
course_id = session.course.id
|
||||
|
||||
if course_id not in [317, 273, 413] and user == session.instructor:
|
||||
msg = 'Self-register trickery detected:\n' + str(data.dict())
|
||||
utils.alert_tanner(msg)
|
||||
raise exceptions.ValidationError(dict(non_field_errors='Can\'t register the instructor. Don\'t try to trick the portal.'))
|
||||
|
||||
training1 = models.Training.objects.filter(user=user, session=session)
|
||||
if training1.exists():
|
||||
@@ -471,19 +503,27 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
|
||||
def get_queryset(self):
|
||||
queryset = models.Transaction.objects
|
||||
month = self.request.query_params.get('month', '')
|
||||
exclude_paypal = self.request.query_params.get('exclude_paypal', '') == 'true'
|
||||
exclude_snacks = self.request.query_params.get('exclude_snacks', '') == 'true'
|
||||
|
||||
if self.action == 'list' and month:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(month, '%Y-%m')
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
|
||||
queryset = queryset.filter(date__year=dt.year)
|
||||
queryset = queryset.filter(date__month=dt.month)
|
||||
queryset = queryset.exclude(category='Memberships:Fake Months')
|
||||
return queryset.order_by('-date', '-id')
|
||||
elif self.action == 'list':
|
||||
queryset = queryset.exclude(report_type__isnull=True)
|
||||
queryset = queryset.exclude(report_type='')
|
||||
if self.action == 'list':
|
||||
if month:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(month, '%Y-%m')
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
|
||||
queryset = queryset.filter(date__year=dt.year)
|
||||
queryset = queryset.filter(date__month=dt.month)
|
||||
queryset = queryset.exclude(category='Memberships:Fake Months')
|
||||
else:
|
||||
queryset = queryset.exclude(report_type__isnull=True)
|
||||
queryset = queryset.exclude(report_type='')
|
||||
|
||||
if exclude_paypal:
|
||||
queryset = queryset.exclude(account_type='PayPal')
|
||||
|
||||
if exclude_snacks:
|
||||
queryset = queryset.exclude(category='Snacks')
|
||||
return queryset.order_by('-date', '-id')
|
||||
else:
|
||||
return queryset.all()
|
||||
@@ -516,16 +556,30 @@ class TransactionViewSet(Base, List, Create, Retrieve, Update):
|
||||
raise exceptions.PermissionDenied()
|
||||
return super().list(request)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def report(self, request, pk=None):
|
||||
report_memo = request.data.get('report_memo', '').strip()
|
||||
if not report_memo:
|
||||
raise exceptions.ValidationError(dict(report_memo='This field may not be blank.'))
|
||||
transaction = self.get_object()
|
||||
transaction.report_type = 'User Flagged'
|
||||
transaction.report_memo = report_memo
|
||||
transaction.save()
|
||||
return Response(200)
|
||||
@action(detail=False, methods=['get'])
|
||||
def summary(self, request):
|
||||
txs = models.Transaction.objects
|
||||
month = self.request.query_params.get('month', '')
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(month, '%Y-%m')
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.'))
|
||||
|
||||
txs = txs.filter(date__year=dt.year)
|
||||
txs = txs.filter(date__month=dt.month)
|
||||
txs = txs.exclude(category='Memberships:Fake Months')
|
||||
|
||||
result = []
|
||||
|
||||
for category in ['Membership', 'Snacks', 'OnAcct', 'Donation', 'Consumables', 'Purchases']:
|
||||
result.append(dict(
|
||||
category = category,
|
||||
dollar = txs.filter(category=category).aggregate(Sum('amount'))['amount__sum'] or 0,
|
||||
protocoin = -1 * (txs.filter(category=category).aggregate(Sum('protocoin'))['protocoin__sum'] or 0),
|
||||
))
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
class UserView(views.APIView):
|
||||
@@ -555,6 +609,7 @@ class DoorViewSet(viewsets.ViewSet, List):
|
||||
for card in cards:
|
||||
member = card.user.member
|
||||
if member.paused_date: continue
|
||||
if not member.vetted_date: continue
|
||||
if not member.is_allowed_entry: continue
|
||||
|
||||
active_member_cards[card.card_number] = '{} ({})'.format(
|
||||
@@ -574,6 +629,13 @@ class DoorViewSet(viewsets.ViewSet, List):
|
||||
t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p')
|
||||
logger.info('Scan - Time: {} | Name: {} {} ({})'.format(t, member.preferred_name, member.last_name, member.id))
|
||||
|
||||
last_scan = dict(
|
||||
time=time.time(),
|
||||
member_id=member.id,
|
||||
first_name=member.preferred_name,
|
||||
)
|
||||
cache.set('last_scan', last_scan)
|
||||
|
||||
utils_stats.calc_card_scans()
|
||||
|
||||
return Response(200)
|
||||
@@ -756,7 +818,8 @@ class StatsViewSet(viewsets.ViewSet, List):
|
||||
if should_count:
|
||||
start_new_use = not last_use or last_use.finished_at or last_use.username != username
|
||||
if start_new_use:
|
||||
if username_isfrom_track and time.time() - track[device]['time'] > 20*60:
|
||||
username_isexpired = time.time() - track[device]['time'] > 2*60*60 # two hours
|
||||
if username_isfrom_track and username_isexpired:
|
||||
msg = 'Usage tracker problem expired username {} for device: {}'.format(username, device)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
@@ -891,6 +954,23 @@ class StatsViewSet(viewsets.ViewSet, List):
|
||||
|
||||
return Response(200)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def printer3d(self, request, pk=None):
|
||||
printer3d = cache.get('printer3d', {})
|
||||
|
||||
devicename = pk
|
||||
status = request.data['result']['status']
|
||||
|
||||
printer3d[devicename] = dict(
|
||||
progress=int(status['display_status']['progress'] * 100),
|
||||
#filename=status['print_stats']['filename'],
|
||||
state=status['idle_timeout']['state'],
|
||||
)
|
||||
cache.set('printer3d', printer3d)
|
||||
|
||||
return Response(200)
|
||||
|
||||
|
||||
|
||||
class MemberCountViewSet(Base, List):
|
||||
pagination_class = None
|
||||
@@ -1071,6 +1151,111 @@ class InterestViewSet(Base, Retrieve, Create):
|
||||
|
||||
|
||||
class ProtocoinViewSet(Base):
|
||||
@action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated])
|
||||
def spend_request(self, request):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
source_user = self.request.user
|
||||
source_member = source_user.member
|
||||
|
||||
training = None
|
||||
|
||||
try:
|
||||
balance = float(request.data['balance'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(balance='This field is required.'))
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(balance='Invalid number.'))
|
||||
|
||||
try:
|
||||
amount = float(request.data['amount'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(amount='This field is required.'))
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(amount='Invalid number.'))
|
||||
|
||||
try:
|
||||
category = str(request.data['category'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(category='This field is required.'))
|
||||
if category not in ['Consumables', 'Donation', 'OnAcct']:
|
||||
raise exceptions.ValidationError(dict(category='Invalid category.'))
|
||||
|
||||
if category == 'OnAcct':
|
||||
try:
|
||||
training_id = int(request.data['training'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(training='This field is required.'))
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(training='Invalid number.'))
|
||||
|
||||
training = get_object_or_404(models.Training, id=training_id)
|
||||
|
||||
if not training.session:
|
||||
raise exceptions.ValidationError(dict(training='Invalid session.'))
|
||||
|
||||
if training.session.is_cancelled:
|
||||
raise exceptions.ValidationError(dict(training='Class is cancelled.'))
|
||||
|
||||
if training.paid_date:
|
||||
raise exceptions.ValidationError(dict(training='Already paid.'))
|
||||
|
||||
if training.session.cost != amount:
|
||||
msg = 'Protocoin training payment amount mismatch:\n' + str(request.data.dict())
|
||||
utils.alert_tanner(msg)
|
||||
raise exceptions.ValidationError(dict(training='Class cost doesn\'t match amount.'))
|
||||
|
||||
memo = str(request.data.get('memo', ''))
|
||||
|
||||
# also prevents negative spending
|
||||
if amount < 0.25:
|
||||
raise exceptions.ValidationError(dict(amount='Amount too small.'))
|
||||
|
||||
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
|
||||
source_user_balance = float(source_user_balance)
|
||||
|
||||
if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
|
||||
raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
|
||||
|
||||
if source_user_balance < amount:
|
||||
raise exceptions.ValidationError(dict(amount='Insufficient funds.'))
|
||||
|
||||
if training:
|
||||
tx_memo = 'Protocoin - Transaction spent ₱ {} on {}, session: {}, training: {}'.format(
|
||||
amount,
|
||||
training.session.course.name,
|
||||
str(training.session.id),
|
||||
str(training.id),
|
||||
)
|
||||
else:
|
||||
tx_memo = 'Protocoin - Transaction spent ₱ {} on {}{}'.format(
|
||||
amount,
|
||||
category,
|
||||
', memo: ' + memo if memo else ''
|
||||
)
|
||||
|
||||
tx = models.Transaction.objects.create(
|
||||
user=source_user,
|
||||
protocoin=-amount,
|
||||
amount=0,
|
||||
number_of_membership_months=0,
|
||||
account_type='Protocoin',
|
||||
category=category,
|
||||
info_source='System',
|
||||
memo=tx_memo,
|
||||
)
|
||||
utils.log_transaction(tx)
|
||||
|
||||
if training:
|
||||
if training.attendance_status == 'Waiting for payment':
|
||||
training.attendance_status = 'Confirmed'
|
||||
training.paid_date = utils.today_alberta_tz()
|
||||
training.save()
|
||||
|
||||
return Response(200)
|
||||
except OperationalError:
|
||||
self.spend_request(request)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated])
|
||||
def send_to_member(self, request):
|
||||
try:
|
||||
@@ -1099,6 +1284,7 @@ class ProtocoinViewSet(Base):
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(amount='Invalid number.'))
|
||||
|
||||
# also prevents negative spending
|
||||
if amount < 1.00:
|
||||
raise exceptions.ValidationError(dict(amount='Amount too small.'))
|
||||
|
||||
@@ -1112,7 +1298,7 @@ class ProtocoinViewSet(Base):
|
||||
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
|
||||
source_user_balance = float(source_user_balance)
|
||||
|
||||
if source_user_balance != balance:
|
||||
if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
|
||||
raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
|
||||
|
||||
if source_user_balance < amount:
|
||||
@@ -1175,6 +1361,38 @@ class ProtocoinViewSet(Base):
|
||||
)
|
||||
return Response(res)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def printer_balance(self, request, pk=None):
|
||||
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
#if secrets.VEND_API_TOKEN and auth_token != 'Bearer ' + secrets.VEND_API_TOKEN:
|
||||
# raise exceptions.PermissionDenied()
|
||||
|
||||
track = cache.get('track', {})
|
||||
track_graphics_computer = track.get('PROTOGRAPH1', None)
|
||||
|
||||
if not track_graphics_computer:
|
||||
return Response(200)
|
||||
|
||||
track_username = track_graphics_computer['username']
|
||||
track_time = track_graphics_computer['time']
|
||||
|
||||
try:
|
||||
source_user = User.objects.get(username__iexact=track_username)
|
||||
except User.DoesNotExist:
|
||||
return Response(200)
|
||||
|
||||
if time.time() - track_time > 10:
|
||||
return Response(200)
|
||||
|
||||
user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
|
||||
user_balance = float(user_balance)
|
||||
|
||||
res = dict(
|
||||
balance=user_balance,
|
||||
first_name=source_user.member.preferred_name,
|
||||
)
|
||||
return Response(res)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def card_vend_request(self, request, pk=None):
|
||||
try:
|
||||
@@ -1186,6 +1404,8 @@ class ProtocoinViewSet(Base):
|
||||
source_card = get_object_or_404(models.Card, card_number=pk)
|
||||
source_user = source_card.user
|
||||
|
||||
machine = request.data.get('machine', 'unknown')
|
||||
|
||||
try:
|
||||
number = request.data['number']
|
||||
except KeyError:
|
||||
@@ -1205,6 +1425,7 @@ class ProtocoinViewSet(Base):
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(amount='Invalid number.'))
|
||||
|
||||
# also prevents negative spending
|
||||
if amount < 0.25:
|
||||
raise exceptions.ValidationError(dict(amount='Amount too small.'))
|
||||
|
||||
@@ -1212,7 +1433,7 @@ class ProtocoinViewSet(Base):
|
||||
source_user_balance = source_user.transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0
|
||||
source_user_balance = float(source_user_balance)
|
||||
|
||||
if source_user_balance != balance:
|
||||
if abs(source_user_balance - balance) > 0.01: # stupid https://docs.djangoproject.com/en/4.2/ref/databases/#decimal-handling
|
||||
raise exceptions.ValidationError(dict(balance='Incorrect current balance.'))
|
||||
|
||||
if source_user_balance < amount:
|
||||
@@ -1220,8 +1441,9 @@ class ProtocoinViewSet(Base):
|
||||
|
||||
source_delta = -amount
|
||||
|
||||
memo = 'Protocoin - Purchase spent ₱ {} on vending machine item #{}'.format(
|
||||
memo = 'Protocoin - Purchase spent ₱ {} on {} vending machine item #{}'.format(
|
||||
amount,
|
||||
machine,
|
||||
number,
|
||||
)
|
||||
|
||||
@@ -1254,6 +1476,128 @@ class ProtocoinViewSet(Base):
|
||||
)
|
||||
return Response(res)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def printer_report(self, request, pk=None):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
#if secrets.VEND_API_TOKEN and auth_token != 'Bearer ' + secrets.VEND_API_TOKEN:
|
||||
# raise exceptions.PermissionDenied()
|
||||
|
||||
# {'job_name': 'download.png', 'uuid': '6abbad4d-dda3-4954-b4f1-ac77933a0562', 'timestamp': '20230211173624',
|
||||
# 'job_status': '0', 'user_name': 'Tanner.Collin', 'source': '1', 'paper_name': 'Plain Paper', 'paper_sqi': '356', 'ink_ul': '54'}
|
||||
|
||||
job_uuid = request.data['uuid']
|
||||
username = request.data['user_name']
|
||||
|
||||
logging.info('New printer job UUID: %s, username: %s', str(job_uuid), str(username))
|
||||
|
||||
if not job_uuid:
|
||||
msg = 'Missing job UUID, aborting.'
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
tx = models.Transaction.objects.filter(reference_number=job_uuid)
|
||||
if tx.exists():
|
||||
msg = 'Job {}: already billed for in transaction {}, aborting.'.format(job_uuid, tx[0].id)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
if not username:
|
||||
msg = 'Job {}: missing username, aborting.'.format(job_uuid)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
# status 0 = complete
|
||||
# status 3 = cancelled
|
||||
|
||||
is_completed = request.data['job_status'] == '0'
|
||||
is_print = request.data['source'] == '1'
|
||||
|
||||
if not is_completed:
|
||||
msg = 'Job {} user {}: not complete, aborting.'.format(job_uuid, username)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
if not is_print:
|
||||
msg = 'Job {} user {}: not a print, aborting.'.format(job_uuid, username)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
try:
|
||||
user = User.objects.get(username__iexact=username)
|
||||
except User.DoesNotExist:
|
||||
msg = 'Job {}: unable to find username {}, aborting.'.format(job_uuid, username)
|
||||
utils.alert_tanner(msg)
|
||||
logger.error(msg)
|
||||
return Response(200)
|
||||
|
||||
INK_PROTOCOIN_PER_ML = 0.75
|
||||
DEFAULT_PAPER_PROTOCOIN_PER_M = 0.50
|
||||
PROTOCOIN_PER_PRINT = 2.0
|
||||
|
||||
total_cost = PROTOCOIN_PER_PRINT
|
||||
logging.info(' Fixed cost: %s', str(PROTOCOIN_PER_PRINT))
|
||||
|
||||
microliters = float(request.data['ink_ul'])
|
||||
millilitres = microliters / 1000.0
|
||||
cost = millilitres * INK_PROTOCOIN_PER_ML
|
||||
total_cost += cost
|
||||
logging.info(' %s ul ink cost: %s', str(microliters), str(cost))
|
||||
|
||||
PAPER_COSTS = {
|
||||
'Plain Paper': 0.25,
|
||||
}
|
||||
|
||||
squareinches = float(request.data['paper_sqi'])
|
||||
squaremetres = squareinches / 1550.0
|
||||
cost = squaremetres * PAPER_COSTS.get(request.data['paper_name'], DEFAULT_PAPER_PROTOCOIN_PER_M)
|
||||
total_cost += cost
|
||||
logging.info(' %s sqi paper cost: %s', str(squareinches), str(cost))
|
||||
|
||||
total_cost = round(total_cost, 2)
|
||||
|
||||
logging.info('Total cost: %s protocoin', str(total_cost))
|
||||
|
||||
memo = 'Protocoin - Purchase spent ₱ {} printing {}'.format(
|
||||
total_cost,
|
||||
request.data['job_name'],
|
||||
)
|
||||
|
||||
tx = models.Transaction.objects.create(
|
||||
user=user,
|
||||
protocoin=-total_cost,
|
||||
amount=0,
|
||||
number_of_membership_months=0,
|
||||
account_type='Protocoin',
|
||||
category='Consumables',
|
||||
info_source='System',
|
||||
reference_number=job_uuid,
|
||||
memo=memo,
|
||||
)
|
||||
utils.log_transaction(tx)
|
||||
|
||||
track = cache.get('track', {})
|
||||
|
||||
devicename = 'LASTLARGEPRINT'
|
||||
first_name = username.split('.')[0].title()
|
||||
|
||||
track[devicename] = dict(
|
||||
time=time.time(),
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
cache.set('track', track)
|
||||
|
||||
return Response(200)
|
||||
except OperationalError:
|
||||
self.printer_report(request, pk)
|
||||
|
||||
|
||||
class PinballViewSet(Base):
|
||||
@action(detail=False, methods=['post'])
|
||||
@@ -1303,6 +1647,143 @@ class PinballViewSet(Base):
|
||||
|
||||
return Response(200)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def get_name(self, request, pk=None):
|
||||
auth_token = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
if secrets.PINBALL_API_TOKEN and auth_token != 'Bearer ' + secrets.PINBALL_API_TOKEN:
|
||||
raise exceptions.PermissionDenied()
|
||||
|
||||
card = get_object_or_404(models.Card, card_number=pk)
|
||||
member = card.user.member
|
||||
|
||||
res = dict(
|
||||
name=member.preferred_name + ' ' + member.last_name[0]
|
||||
)
|
||||
return Response(res)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def high_scores(self, request):
|
||||
members = models.Member.objects.all()
|
||||
members = members.annotate(
|
||||
pinball_score=Max('user__scores__score'),
|
||||
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
|
||||
|
||||
scores = []
|
||||
|
||||
for member in members:
|
||||
scores.append(dict(
|
||||
name=member.preferred_name + ' ' + member.last_name[0],
|
||||
score=member.pinball_score,
|
||||
member_id=member.id,
|
||||
))
|
||||
|
||||
return Response(scores)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def monthly_high_scores(self, request):
|
||||
now = utils.now_alberta_tz()
|
||||
current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
members = models.Member.objects.all()
|
||||
members = members.annotate(
|
||||
pinball_score=Max('user__scores__score', filter=Q(user__scores__finished_at__gte=current_month_start)),
|
||||
).exclude(pinball_score__isnull=True).order_by('-pinball_score')
|
||||
|
||||
scores = []
|
||||
|
||||
for member in members:
|
||||
scores.append(dict(
|
||||
name=member.preferred_name + ' ' + member.last_name[0],
|
||||
score=member.pinball_score,
|
||||
member_id=member.id,
|
||||
))
|
||||
|
||||
return Response(scores)
|
||||
|
||||
|
||||
class HostingViewSet(Base):
|
||||
@action(detail=False, methods=['post'])
|
||||
def offer(self, request):
|
||||
#auth_token = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
#if secrets.PINBALL_API_TOKEN and auth_token != 'Bearer ' + secrets.PINBALL_API_TOKEN:
|
||||
# raise exceptions.PermissionDenied()
|
||||
|
||||
try:
|
||||
member_id = int(request.data['member_id'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(game_id='This field is required.'))
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(game_id='Invalid number.'))
|
||||
|
||||
try:
|
||||
hours = int(request.data['hours'])
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(player='This field is required.'))
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(player='Invalid number.'))
|
||||
|
||||
hosting_member = get_object_or_404(models.Member, id=member_id)
|
||||
hosting_user = hosting_member.user
|
||||
|
||||
logging.info('Hosting offer from %s %s for %s hours', hosting_member.preferred_name, hosting_member.last_name, hours)
|
||||
|
||||
try:
|
||||
current_hosting = models.Hosting.objects.get(user=hosting_user, finished_at__gte=now())
|
||||
logging.info('Current hosting by member: %s', current_hosting)
|
||||
new_end = now() + datetime.timedelta(hours=hours)
|
||||
new_delta = new_end - current_hosting.started_at
|
||||
new_hours = new_delta.seconds / 3600
|
||||
|
||||
logging.info(
|
||||
'Hosting %s from %s is still going, updating hours from %s to %s.',
|
||||
current_hosting.id,
|
||||
current_hosting.started_at,
|
||||
current_hosting.hours,
|
||||
new_hours
|
||||
)
|
||||
|
||||
current_hosting.finished_at = new_end
|
||||
current_hosting.hours = new_hours
|
||||
current_hosting.save()
|
||||
|
||||
except models.Hosting.DoesNotExist:
|
||||
h = models.Hosting.objects.create(
|
||||
user=hosting_user,
|
||||
hours=hours,
|
||||
finished_at=now() + datetime.timedelta(hours=hours),
|
||||
)
|
||||
|
||||
logging.info('No current hosting for that user, new hosting #%s created.', h.id)
|
||||
|
||||
# update "open until" time
|
||||
hosting = models.Hosting.objects.order_by('-finished_at').first()
|
||||
closing = dict(
|
||||
time=hosting.finished_at.timestamp(),
|
||||
time_str=hosting.finished_at.astimezone(utils.TIMEZONE_CALGARY).strftime('%-I:%M %p'),
|
||||
first_name=hosting.user.member.preferred_name,
|
||||
)
|
||||
cache.set('closing', closing)
|
||||
|
||||
return Response(200)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def high_scores(self, request):
|
||||
members = models.Member.objects.all()
|
||||
members = members.annotate(
|
||||
hosting_hours=Sum('user__hosting__hours'),
|
||||
).exclude(hosting_hours__isnull=True).order_by('-hosting_hours')
|
||||
|
||||
hours = []
|
||||
|
||||
for member in members:
|
||||
hours.append(dict(
|
||||
name=member.preferred_name + ' ' + member.last_name[0],
|
||||
hours=member.hosting_hours,
|
||||
member_id=member.id,
|
||||
))
|
||||
|
||||
return Response(hours)
|
||||
|
||||
|
||||
class StorageSpaceViewSet(Base, List, Retrieve, Update):
|
||||
permission_classes = [AllowMetadata | IsAdmin]
|
||||
|
@@ -217,7 +217,7 @@ if DEBUG:
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 300,
|
||||
'PAGE_SIZE': 500,
|
||||
'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES,
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES,
|
||||
'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'],
|
||||
@@ -250,6 +250,11 @@ LOGGING = {
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
#'django.db.backends': {
|
||||
# 'handlers': ['console'],
|
||||
# 'level': 'DEBUG',
|
||||
# 'propagate': False,
|
||||
# },
|
||||
'gunicorn': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG' if DEBUG else 'INFO',
|
||||
|
@@ -21,6 +21,7 @@ router.register(r'history', views.HistoryViewSet, basename='history')
|
||||
router.register(r'vetting', views.VettingViewSet, basename='vetting')
|
||||
router.register(r'pinball', views.PinballViewSet, basename='pinball')
|
||||
router.register(r'storage', views.StorageSpaceViewSet, basename='storage')
|
||||
router.register(r'hosting', views.HostingViewSet, basename='hosting')
|
||||
router.register(r'sessions', views.SessionViewSet, basename='session')
|
||||
router.register(r'training', views.TrainingViewSet, basename='training')
|
||||
router.register(r'interest', views.InterestViewSet, basename='interest')
|
||||
|
@@ -73,4 +73,5 @@ typing-extensions==4.0.1
|
||||
urllib3==1.25.11
|
||||
wcwidth==0.2.5
|
||||
webencodings==0.5.1
|
||||
xmltodict==0.13.0
|
||||
zipp==3.8.1
|
||||
|
32
apiserver/scripts/distinguish_paused_expired.py
Executable file
32
apiserver/scripts/distinguish_paused_expired.py
Executable file
@@ -0,0 +1,32 @@
|
||||
# will not work after expired date change
|
||||
# =======================================
|
||||
|
||||
import django, sys, os
|
||||
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
|
||||
django.setup()
|
||||
|
||||
from dateutil import relativedelta
|
||||
|
||||
from apiserver.api import models
|
||||
|
||||
members = models.Member.objects.all()
|
||||
count = 0
|
||||
|
||||
for m in members:
|
||||
if m.paused_date and m.status == 'Former Member':
|
||||
print('Former member', m.preferred_name, m.last_name)
|
||||
|
||||
if m.paused_date == m.expire_date:
|
||||
new_status = 'Expired Member'
|
||||
new_paused_date = m.paused_date + relativedelta.relativedelta(months=3)
|
||||
print(' Moving paused date', m.paused_date, '-->', new_paused_date)
|
||||
m.paused_date = new_paused_date
|
||||
else:
|
||||
new_status = 'Paused Member'
|
||||
|
||||
print(' Setting status to', new_status)
|
||||
m.status = new_status
|
||||
count += 1
|
||||
m.save()
|
||||
|
||||
print('Processed', count)
|
@@ -8,7 +8,7 @@ from apiserver.api import models
|
||||
sessions = models.Session.objects.filter(datetime__gte='2021-01-01')
|
||||
|
||||
with open('output.csv', 'w', newline='') as csvfile:
|
||||
fields = ['date', 'name', 'num_students']
|
||||
fields = ['date', 'name', 'num_students','attended']
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fields)
|
||||
|
||||
writer.writeheader()
|
||||
@@ -17,6 +17,7 @@ with open('output.csv', 'w', newline='') as csvfile:
|
||||
writer.writerow(dict(
|
||||
date=s.datetime.date(),
|
||||
name=s.course.name,
|
||||
num_students=s.students.filter(attendance_status='Attended').count(),
|
||||
num_students=s.students.count(),
|
||||
attended=s.students.filter(attendance_status='Attended').count(),
|
||||
))
|
||||
|
||||
|
Reference in New Issue
Block a user