Merge branch 'master' into storage_space

This commit is contained in:
2023-05-29 12:16:51 -06:00
36 changed files with 1407 additions and 210 deletions

View File

@@ -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:

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

View 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

View File

@@ -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]
))

View File

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

View File

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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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)

View File

@@ -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))

View File

@@ -28,6 +28,9 @@ DEFAULTS = {
'sign': '',
'link': '',
'autoscan': '',
'last_scan': {},
'closing': {},
'printer3d': {},
}
if secrets.MUMBLE:

View File

@@ -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]

View File

@@ -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',

View File

@@ -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')

View File

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

View 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)

View File

@@ -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(),
))