|
|
import logging |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
from django.contrib.auth.models import User, Group |
|
|
from django.shortcuts import get_object_or_404, redirect |
|
|
from django.db import transaction |
|
|
from django.db.models import Max, F, Count, Q, Sum |
|
|
from django.db.utils import OperationalError |
|
|
from django.http import HttpResponse, Http404, FileResponse, HttpResponseServerError |
|
|
from django.core.files.base import File |
|
|
from django.core.cache import cache |
|
|
from django.utils.timezone import now |
|
|
from rest_framework import viewsets, views, mixins, generics, exceptions |
|
|
from rest_framework.decorators import action, api_view |
|
|
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly |
|
|
from rest_framework.response import Response |
|
|
from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView, LoginView |
|
|
from rest_auth.registration.views import RegisterView |
|
|
from fuzzywuzzy import fuzz, process |
|
|
from collections import OrderedDict |
|
|
from dateutil import relativedelta |
|
|
import icalendar |
|
|
import datetime, time |
|
|
import io |
|
|
import csv |
|
|
import xmltodict |
|
|
|
|
|
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap, utils_email |
|
|
from .permissions import ( |
|
|
is_admin_director, |
|
|
AllowMetadata, |
|
|
IsObjOwnerOrAdmin, |
|
|
IsSessionInstructorOrAdmin, |
|
|
ReadOnly, |
|
|
IsAdmin, |
|
|
IsAdminOrReadOnly, |
|
|
IsInstructorOrReadOnly |
|
|
) |
|
|
from .. import settings, secrets |
|
|
|
|
|
# define some shortcuts |
|
|
Base = viewsets.GenericViewSet |
|
|
List = mixins.ListModelMixin |
|
|
Retrieve = mixins.RetrieveModelMixin |
|
|
Create = mixins.CreateModelMixin |
|
|
Update = mixins.UpdateModelMixin |
|
|
Destroy = mixins.DestroyModelMixin |
|
|
|
|
|
NUM_RESULTS = 100 |
|
|
|
|
|
|
|
|
class SearchViewSet(Base, Retrieve): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated] |
|
|
|
|
|
def get_serializer_class(self): |
|
|
if is_admin_director(self.request.user) and self.action == 'retrieve': |
|
|
return serializers.AdminSearchSerializer |
|
|
elif self.request.user.member.is_instructor and self.action == 'retrieve': |
|
|
return serializers.InstructorSearchSerializer |
|
|
elif self.request.user.member.vetted_date: |
|
|
return serializers.VettedSearchSerializer |
|
|
else: |
|
|
return serializers.SearchSerializer |
|
|
|
|
|
def get_queryset(self): |
|
|
queryset = models.Member.objects.all() |
|
|
search = self.request.data.get('q', '').lower() |
|
|
sort = self.request.data.get('sort', '').lower() |
|
|
|
|
|
if not cache.touch('search_strings'): |
|
|
utils.gen_search_strings() # init cache |
|
|
|
|
|
search_strings = cache.get('search_strings', {}) |
|
|
|
|
|
if len(search): |
|
|
choices = search_strings.keys() |
|
|
|
|
|
# get exact starts with matches |
|
|
results = [x for x in choices if x.startswith(search)] |
|
|
# then get exact substring matches |
|
|
results += [x for x in choices if search in x] |
|
|
|
|
|
if len(results) == 0 and len(search) >= 3 and '@' not in search: |
|
|
# then get fuzzy matches, but not for emails |
|
|
fuzzy_results = process.extract(search, choices, limit=20, scorer=fuzz.token_set_ratio) |
|
|
results += [x[0] for x in fuzzy_results] |
|
|
|
|
|
# remove dupes, truncate list |
|
|
results = list(OrderedDict.fromkeys(results))[:20] |
|
|
|
|
|
result_ids = [search_strings[x] for x in results] |
|
|
result_objects = [queryset.get(id=x) for x in result_ids] |
|
|
|
|
|
queryset = result_objects |
|
|
logging.info('Search for: {}, results: {}'.format(search, len(queryset))) |
|
|
elif self.action == 'create': |
|
|
if sort == 'recently_vetted': |
|
|
queryset = queryset.filter(vetted_date__isnull=False) |
|
|
queryset = queryset.order_by('-vetted_date', '-id') |
|
|
elif sort == 'newest_active': |
|
|
queryset = queryset.filter(paused_date__isnull=True) |
|
|
queryset = queryset.order_by('-application_date', '-id') |
|
|
elif sort == 'newest_overall': |
|
|
queryset = queryset.order_by('-application_date', '-id') |
|
|
elif sort == 'oldest_active': |
|
|
queryset = queryset.filter(paused_date__isnull=True) |
|
|
queryset = queryset.order_by('application_date', 'id') |
|
|
elif sort == 'oldest_overall': |
|
|
queryset = queryset.filter(application_date__isnull=False) |
|
|
queryset = queryset.order_by('application_date', 'id') |
|
|
elif sort == 'recently_inactive': |
|
|
queryset = queryset.filter(paused_date__isnull=False) |
|
|
queryset = queryset.order_by('-paused_date') |
|
|
elif sort == 'is_director': |
|
|
queryset = queryset.filter(is_director=True) |
|
|
queryset = queryset.order_by('application_date', 'id') |
|
|
elif sort == 'is_instructor': |
|
|
queryset = queryset.filter(paused_date__isnull=True, is_instructor=True) |
|
|
queryset = queryset.order_by('application_date', 'id') |
|
|
elif sort == 'due': |
|
|
queryset = queryset.filter(status='Due') |
|
|
queryset = queryset.order_by('expire_date', 'id') |
|
|
elif sort == 'overdue': |
|
|
queryset = queryset.filter(status='Overdue') |
|
|
queryset = queryset.order_by('expire_date', 'id') |
|
|
elif sort == 'last_scanned': |
|
|
if self.request.user.member.allow_last_scanned: |
|
|
queryset = queryset.filter(allow_last_scanned=True) |
|
|
queryset = queryset.annotate( |
|
|
last_scanned=Max('user__cards__last_seen'), |
|
|
).exclude(last_scanned__isnull=True).order_by('-last_scanned') |
|
|
else: |
|
|
queryset = [] |
|
|
elif sort == 'pinball_score': |
|
|
queryset = queryset.annotate( |
|
|
pinball_score=Max('user__scores__score'), |
|
|
).exclude(pinball_score__isnull=True).order_by('-pinball_score') |
|
|
elif sort == 'storage': |
|
|
queryset = queryset.annotate( |
|
|
storage_count=Count('user__storage'), |
|
|
).exclude(storage_count=0).order_by('-storage_count', 'id') |
|
|
elif sort == 'everyone': |
|
|
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 = [] |
|
|
|
|
|
return queryset |
|
|
|
|
|
# must POST so query string doesn't change so preflight request is cached |
|
|
# to save an OPTIONS request so search is fast |
|
|
def create(self, request): |
|
|
try: |
|
|
seq = int(request.data.get('seq', 0)) |
|
|
except ValueError: |
|
|
seq = 0 |
|
|
|
|
|
search = self.request.data.get('q', '').lower() |
|
|
page = self.request.data.get('page', 0) |
|
|
queryset = self.get_queryset() |
|
|
total = len(queryset) |
|
|
|
|
|
start = int(page) * NUM_RESULTS - 80 |
|
|
queryset = queryset[max(start,0):start+NUM_RESULTS] |
|
|
|
|
|
if self.request.user.member.vetted_date: |
|
|
serializer = serializers.VettedSearchSerializer(queryset, many=True) |
|
|
else: |
|
|
serializer = serializers.SearchSerializer(queryset, many=True) |
|
|
|
|
|
return Response({'seq': seq, 'results': serializer.data, 'total': total}) |
|
|
|
|
|
|
|
|
class MemberViewSet(Base, Retrieve, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated, IsObjOwnerOrAdmin] |
|
|
queryset = models.Member.objects.all() |
|
|
|
|
|
def get_serializer_class(self): |
|
|
if is_admin_director(self.request.user): |
|
|
return serializers.AdminMemberSerializer |
|
|
else: |
|
|
return serializers.MemberSerializer |
|
|
|
|
|
def perform_update(self, serializer): |
|
|
member = serializer.save() |
|
|
utils.tally_membership_months(member) |
|
|
utils.gen_member_forms(member) |
|
|
|
|
|
@action(detail=True, methods=['post']) |
|
|
def pause(self, request, pk=None): |
|
|
if not is_admin_director(self.request.user): |
|
|
raise exceptions.PermissionDenied() |
|
|
member = self.get_object() |
|
|
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() |
|
|
|
|
|
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) |
|
|
utils_stats.changed_card() |
|
|
return Response(200) |
|
|
|
|
|
@action(detail=True, methods=['get']) |
|
|
def card_photo(self, request, pk=None): |
|
|
if not is_admin_director(self.request.user): |
|
|
raise exceptions.PermissionDenied() |
|
|
member = self.get_object() |
|
|
if not member.photo_large: |
|
|
raise Http404 |
|
|
card_photo = utils.gen_card_photo(member) |
|
|
return FileResponse(card_photo, filename='card.jpg') |
|
|
|
|
|
|
|
|
class CardViewSet(Base, Create, Retrieve, Update): |
|
|
permission_classes = [AllowMetadata | IsAdmin] |
|
|
queryset = models.Card.objects.all() |
|
|
serializer_class = serializers.CardSerializer |
|
|
|
|
|
def perform_create(self, serializer): |
|
|
serializer.save() |
|
|
utils_stats.changed_card() |
|
|
|
|
|
def perform_update(self, serializer): |
|
|
serializer.save() |
|
|
utils_stats.changed_card() |
|
|
|
|
|
|
|
|
# TODO: return nested list of sessions, limited with Prefetch: |
|
|
# https://stackoverflow.com/a/58689019 |
|
|
class CourseViewSet(Base, List, Retrieve, Create, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticatedOrReadOnly, IsAdminOrReadOnly | IsInstructorOrReadOnly] |
|
|
queryset = models.Course.objects.annotate( |
|
|
date=Max('sessions__datetime'), |
|
|
num_interested=Count('interests', filter=Q(interests__satisfied_by__isnull=True), distinct=True), |
|
|
).order_by( |
|
|
'-num_interested', |
|
|
'-date', |
|
|
) |
|
|
|
|
|
def get_serializer_class(self): |
|
|
if self.action == 'list': |
|
|
return serializers.CourseSerializer |
|
|
else: |
|
|
return serializers.CourseDetailSerializer |
|
|
|
|
|
|
|
|
class SessionViewSet(Base, List, Retrieve, Create, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticatedOrReadOnly, IsAdminOrReadOnly | IsInstructorOrReadOnly] |
|
|
|
|
|
def get_queryset(self): |
|
|
if self.action == 'list': |
|
|
week_ago = now() - datetime.timedelta(days=7) |
|
|
year_ago = now() - datetime.timedelta(days=365) |
|
|
|
|
|
return models.Session.objects.annotate( |
|
|
course_count=Count( |
|
|
'course__sessions', |
|
|
filter=Q( |
|
|
course__sessions__datetime__gte=year_ago, |
|
|
), |
|
|
), |
|
|
).filter( |
|
|
datetime__gte=week_ago, |
|
|
).order_by( |
|
|
'-course_count', |
|
|
'-course_id', |
|
|
'datetime', |
|
|
) |
|
|
else: |
|
|
return models.Session.objects.all() |
|
|
|
|
|
def get_serializer_class(self): |
|
|
if self.action == 'list': |
|
|
return serializers.SessionListSerializer |
|
|
else: |
|
|
return serializers.SessionSerializer |
|
|
|
|
|
def perform_create(self, serializer): |
|
|
data = self.request.data |
|
|
session = serializer.save(instructor=self.request.user) |
|
|
|
|
|
# ensure session datetime is at least 1 day in the future |
|
|
# before sending interest emails |
|
|
if session.datetime < now() + datetime.timedelta(days=1): |
|
|
logging.info('Session is in the past or too soon, not sending interest emails.') |
|
|
return |
|
|
|
|
|
interests = models.Interest.objects.filter( |
|
|
course=session.course, |
|
|
satisfied_by__isnull=True, |
|
|
user__member__paused_date__isnull=True |
|
|
)[:20] |
|
|
|
|
|
for num, interest in enumerate(interests): |
|
|
msg = 'Sending email {} / {}...'.format(num+1, len(interests)) |
|
|
if data['request_id']: utils_stats.set_progress(data['request_id'], msg, replace=True) |
|
|
|
|
|
try: |
|
|
utils_email.send_interest_email(interest) |
|
|
except BaseException as e: |
|
|
msg = 'Problem sending interest email: ' + str(e) |
|
|
logger.exception(msg) |
|
|
utils.alert_tanner(msg) |
|
|
|
|
|
interest_ids = interests.values('id') |
|
|
num_satisfied = models.Interest.objects.filter(id__in=interest_ids).update(satisfied_by=session) |
|
|
|
|
|
logging.info('Satisfied %s interests.', num_satisfied) |
|
|
|
|
|
def generate_ical(self, session): |
|
|
cal = icalendar.Calendar() |
|
|
cal.add('prodid', '-//Protospace//Spaceport//') |
|
|
cal.add('version', '2.0') |
|
|
|
|
|
event = icalendar.Event() |
|
|
event.add('summary', session.course.name) |
|
|
event.add('dtstart', session.datetime) |
|
|
event.add('dtend', session.datetime + datetime.timedelta(hours=1)) |
|
|
event.add('dtstamp', now()) |
|
|
|
|
|
cal.add_component(event) |
|
|
|
|
|
return cal.to_ical() |
|
|
|
|
|
@action(detail=True, methods=['get']) |
|
|
def download_ical(self, request, pk=None): |
|
|
session = get_object_or_404(models.Session, id=pk) |
|
|
user = self.request.user |
|
|
|
|
|
ical_file = self.generate_ical(session).decode() |
|
|
|
|
|
response = FileResponse(ical_file, filename='event.ics') |
|
|
response['Content-Type'] = 'text/calendar' |
|
|
response['Content-Disposition'] = 'attachment; filename="event.ics"' |
|
|
|
|
|
return response |
|
|
|
|
|
@action(detail=True, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated]) |
|
|
def email_ical(self, request, pk=None): |
|
|
session = get_object_or_404(models.Session, id=pk) |
|
|
user = self.request.user |
|
|
|
|
|
ical_file = self.generate_ical(session).decode() |
|
|
|
|
|
utils_email.send_ical_email(user.member, session, ical_file) |
|
|
|
|
|
return Response(200) |
|
|
|
|
|
|
|
|
class TrainingViewSet(Base, Retrieve, Create, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated, IsObjOwnerOrAdmin | IsSessionInstructorOrAdmin | ReadOnly] |
|
|
serializer_class = serializers.TrainingSerializer |
|
|
queryset = models.Training.objects.all() |
|
|
|
|
|
def get_serializer_class(self): |
|
|
user = self.request.user |
|
|
if is_admin_director(user) or user.member.is_instructor: |
|
|
return serializers.TrainingSerializer |
|
|
else: |
|
|
return serializers.StudentTrainingSerializer |
|
|
|
|
|
def update_cert(self, session, member, status): |
|
|
def check_attendance(requires_vetted=False): |
|
|
if requires_vetted: |
|
|
if status == 'Attended' and member.vetted_date: |
|
|
logging.info('Granting certification: %s', session.course.name) |
|
|
return utils.today_alberta_tz() |
|
|
else: |
|
|
if status == 'Attended': |
|
|
logging.info('Granting certification: %s', session.course.name) |
|
|
return utils.today_alberta_tz() |
|
|
|
|
|
logging.info('Not granting certification: %s', session.course.name) |
|
|
return None |
|
|
|
|
|
# always update cert date incase member is returning and gets recertified |
|
|
if session.course.id == 249: |
|
|
member.orientation_date = check_attendance() |
|
|
elif session.course.id == 463: |
|
|
member.wood_cert_date = check_attendance() |
|
|
elif session.course.id == 401: |
|
|
member.wood2_cert_date = check_attendance() |
|
|
elif session.course.id == 281: |
|
|
member.lathe_cert_date = check_attendance() |
|
|
elif session.course.id == 283: |
|
|
member.mill_cert_date = check_attendance() |
|
|
elif session.course.id == 259: |
|
|
member.tormach_cnc_cert_date = check_attendance() |
|
|
elif session.course.id == 428: |
|
|
member.precix_cnc_cert_date = check_attendance(requires_vetted=True) |
|
|
|
|
|
if utils_ldap.is_configured(): |
|
|
if member.precix_cnc_cert_date: |
|
|
utils_ldap.add_to_group(member, 'CNC-Precix-Users') |
|
|
else: |
|
|
utils_ldap.remove_from_group(member, 'CNC-Precix-Users') |
|
|
elif session.course.id == 247: |
|
|
member.rabbit_cert_date = check_attendance(requires_vetted=True) |
|
|
|
|
|
if utils_ldap.is_configured(): |
|
|
if member.rabbit_cert_date: |
|
|
utils_ldap.add_to_group(member, 'Laser Users') |
|
|
else: |
|
|
utils_ldap.remove_from_group(member, 'Laser Users') |
|
|
elif session.course.id == 321: |
|
|
member.trotec_cert_date = check_attendance(requires_vetted=True) |
|
|
|
|
|
if utils_ldap.is_configured(): |
|
|
if member.trotec_cert_date: |
|
|
utils_ldap.add_to_group(member, 'Trotec Users') |
|
|
else: |
|
|
utils_ldap.remove_from_group(member, 'Trotec Users') |
|
|
member.save() |
|
|
|
|
|
# TODO: turn these into @actions |
|
|
# TODO: check if full, but not for instructors |
|
|
# TODO: if already paid, skip to confirmed |
|
|
def perform_create(self, serializer): |
|
|
user = self.request.user |
|
|
data = self.request.data |
|
|
session_id = data['session'] |
|
|
status = data['attendance_status'] |
|
|
session = get_object_or_404(models.Session, id=session_id) |
|
|
|
|
|
if data.get('member_id', None): |
|
|
if not (is_admin_director(user) or session.instructor == user): |
|
|
raise exceptions.ValidationError(dict(non_field_errors='Not allowed to register others')) |
|
|
|
|
|
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(): |
|
|
raise exceptions.ValidationError(dict(non_field_errors='Already registered.')) |
|
|
|
|
|
self.update_cert(session, member, status) |
|
|
|
|
|
serializer.save(user=user, attendance_status=status) |
|
|
else: |
|
|
training = models.Training.objects.filter(user=user, session=session) |
|
|
if training.exists(): |
|
|
raise exceptions.ValidationError(dict(non_field_errors='Already registered')) |
|
|
if user == session.instructor: |
|
|
raise exceptions.ValidationError(dict(non_field_errors='You are teaching this session')) |
|
|
if status == 'Waiting for payment' and session.cost == 0: |
|
|
status = 'Confirmed' |
|
|
serializer.save(user=user, attendance_status=status) |
|
|
|
|
|
def perform_update(self, serializer): |
|
|
session_id = self.request.data['session'] |
|
|
status = self.request.data['attendance_status'] |
|
|
session = get_object_or_404(models.Session, id=session_id) |
|
|
if status == 'Waiting for payment' and session.cost == 0: |
|
|
status = 'Confirmed' |
|
|
|
|
|
training = serializer.save(attendance_status=status) |
|
|
member = training.user.member |
|
|
|
|
|
self.update_cert(session, member, status) |
|
|
|
|
|
|
|
|
class TransactionViewSet(Base, List, Create, Retrieve, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated, IsObjOwnerOrAdmin] |
|
|
serializer_class = serializers.TransactionSerializer |
|
|
|
|
|
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' |
|
|
exclude_dues = self.request.query_params.get('exclude_dues', '') == 'true' |
|
|
|
|
|
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') |
|
|
|
|
|
if exclude_dues: |
|
|
queryset = queryset.exclude(category='Membership') |
|
|
return queryset.order_by('-date', '-id') |
|
|
else: |
|
|
return queryset.all() |
|
|
|
|
|
def retally_membership(self): |
|
|
member_id = self.request.data['member_id'] |
|
|
member = get_object_or_404(models.Member, id=member_id) |
|
|
utils.tally_membership_months(member) |
|
|
|
|
|
def train_paypal_hint(self, tx): |
|
|
if tx.paypal_payer_id: |
|
|
models.PayPalHint.objects.update_or_create( |
|
|
account=tx.paypal_payer_id, |
|
|
defaults=dict(user=tx.user), |
|
|
) |
|
|
|
|
|
def perform_create(self, serializer): |
|
|
tx = serializer.save(recorder=self.request.user) |
|
|
utils.log_transaction(tx) |
|
|
self.retally_membership() |
|
|
|
|
|
def perform_update(self, serializer): |
|
|
tx = serializer.save() |
|
|
utils.log_transaction(tx) |
|
|
self.retally_membership() |
|
|
self.train_paypal_hint(tx) |
|
|
|
|
|
def list(self, request): |
|
|
if not is_admin_director(self.request.user): |
|
|
raise exceptions.PermissionDenied() |
|
|
return super().list(request) |
|
|
|
|
|
@action(detail=False, methods=['get']) |
|
|
def summary(self, request): |
|
|
txs = models.Transaction.objects |
|
|
month = self.request.query_params.get('month', '') |
|
|
|
|
|
try: |
|
|
dt = datetime.datetime.strptime(month, '%Y-%m') |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.')) |
|
|
|
|
|
txs = txs.filter(date__year=dt.year) |
|
|
txs = txs.filter(date__month=dt.month) |
|
|
txs = txs.exclude(category='Memberships:Fake Months') |
|
|
|
|
|
result = [] |
|
|
|
|
|
for category in ['Membership', 'Snacks', 'OnAcct', 'Donation', 'Consumables', 'Purchases']: |
|
|
result.append(dict( |
|
|
category = category, |
|
|
dollar = txs.filter(category=category).aggregate(Sum('amount'))['amount__sum'] or 0, |
|
|
protocoin = -1 * (txs.filter(category=category).aggregate(Sum('protocoin'))['protocoin__sum'] or 0), |
|
|
)) |
|
|
|
|
|
return Response(result) |
|
|
|
|
|
|
|
|
class UserView(views.APIView): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated] |
|
|
|
|
|
def get(self, request): |
|
|
serializer = serializers.UserSerializer(request.user) |
|
|
return Response(serializer.data) |
|
|
|
|
|
|
|
|
class PingView(views.APIView): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated] |
|
|
|
|
|
def post(self, request): |
|
|
return Response(200) |
|
|
|
|
|
|
|
|
class DoorViewSet(viewsets.ViewSet, List): |
|
|
def list(self, request): |
|
|
auth_token = request.META.get('HTTP_AUTHORIZATION', '') |
|
|
if secrets.DOOR_API_TOKEN and auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: |
|
|
raise exceptions.PermissionDenied() |
|
|
|
|
|
cards = models.Card.objects.filter(active_status='card_active') |
|
|
active_member_cards = {} |
|
|
|
|
|
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( |
|
|
member.preferred_name + ' ' + member.last_name[0], |
|
|
member.id, |
|
|
) |
|
|
|
|
|
return Response(active_member_cards) |
|
|
|
|
|
@action(detail=True, methods=['post']) |
|
|
def seen(self, request, pk=None): |
|
|
card = get_object_or_404(models.Card, card_number=pk) |
|
|
card.last_seen = now() |
|
|
card.save() |
|
|
|
|
|
member = card.user.member |
|
|
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) |
|
|
|
|
|
|
|
|
class LockoutViewSet(viewsets.ViewSet, List): |
|
|
def list(self, request): |
|
|
auth_token = request.META.get('HTTP_AUTHORIZATION', '') |
|
|
if secrets.DOOR_API_TOKEN and auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: |
|
|
raise exceptions.PermissionDenied() |
|
|
|
|
|
cards = models.Card.objects.filter(active_status='card_active') |
|
|
active_member_cards = {} |
|
|
|
|
|
for card in cards: |
|
|
member = card.user.member |
|
|
if member.paused_date: continue |
|
|
if not member.is_allowed_entry: continue |
|
|
|
|
|
authorization = {} |
|
|
authorization['id'] = member.id |
|
|
authorization['name'] = member.preferred_name + ' ' + member.last_name |
|
|
authorization['common'] = bool(member.orientation_date or member.vetted_date) |
|
|
authorization['lathe'] = bool(member.lathe_cert_date) and authorization['common'] |
|
|
authorization['mill'] = bool(member.mill_cert_date) and authorization['common'] |
|
|
authorization['wood'] = bool(member.wood_cert_date) and authorization['common'] |
|
|
authorization['wood2'] = bool(member.wood2_cert_date) and authorization['common'] |
|
|
authorization['cnc'] = bool(member.tormach_cnc_cert_date) and authorization['common'] |
|
|
authorization['tormach_cnc'] = bool(member.tormach_cnc_cert_date) and authorization['common'] |
|
|
authorization['precix_cnc'] = bool(member.precix_cnc_cert_date) and authorization['common'] |
|
|
|
|
|
active_member_cards[card.card_number] = authorization |
|
|
|
|
|
return Response(active_member_cards) |
|
|
|
|
|
|
|
|
class IpnView(views.APIView): |
|
|
def post(self, request): |
|
|
try: |
|
|
utils_paypal.process_paypal_ipn(request.data) |
|
|
except BaseException as e: |
|
|
logger.error('IPN route - {} - {}'.format(e.__class__.__name__, str(e))) |
|
|
return HttpResponseServerError() |
|
|
|
|
|
return Response(200) |
|
|
|
|
|
|
|
|
class StatsViewSet(viewsets.ViewSet, List): |
|
|
def list(self, request): |
|
|
stats_keys = utils_stats.DEFAULTS.keys() |
|
|
cached_stats = cache.get_many(stats_keys) |
|
|
stats = utils_stats.DEFAULTS.copy() |
|
|
stats.update(cached_stats) |
|
|
|
|
|
user = self.request.user |
|
|
if not user.is_authenticated: |
|
|
stats.pop('alarm', None) |
|
|
stats.pop('autoscan', None) |
|
|
|
|
|
stats['at_protospace'] = utils.is_request_from_protospace(request) |
|
|
|
|
|
return Response(stats) |
|
|
|
|
|
@action(detail=False, methods=['get']) |
|
|
def progress(self, request): |
|
|
try: |
|
|
request_id = request.query_params['request_id'] |
|
|
return Response(utils_stats.get_progress(request_id)) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(request_id='This field is required.')) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def bay_108_temp(self, request): |
|
|
try: |
|
|
cache.set('bay_108_temp', round(float(request.data['data']), 1)) |
|
|
return Response(200) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(data='Invalid float.')) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(data='This field is required.')) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def bay_110_temp(self, request): |
|
|
try: |
|
|
cache.set('bay_110_temp', round(float(request.data['data']), 1)) |
|
|
return Response(200) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(data='Invalid float.')) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(data='This field is required.')) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def sign(self, request): |
|
|
try: |
|
|
sign = request.data['sign'][:500] |
|
|
|
|
|
sign = sign.replace('‘', '\'').replace('’', '\'') |
|
|
sign = sign.replace('“', '"').replace('”', '"') |
|
|
sign = sign.replace('…', '...') |
|
|
|
|
|
if sign.startswith('https://') or sign.startswith('http://'): |
|
|
cache.set('link', sign) |
|
|
else: |
|
|
cache.set('sign', sign) |
|
|
|
|
|
return Response(200) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(sign='This field is required.')) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def alarm(self, request): |
|
|
# Sample messages: |
|
|
# {'data': 'Connected'} |
|
|
# {'data': 'Trouble status on'} |
|
|
# {'data': 'Exit delay in progress: Partition 1'} |
|
|
# {'data': 'Disarmed: Partition 1'} |
|
|
# {'data': 'Armed away: Partition 1'} |
|
|
# {'data': 'Alarm: Partition 1'} |
|
|
# {'data': 'Alarm: Partition 2'} |
|
|
# {'data': 'Zone alarm restored: 1'} |
|
|
# {'data': 'Zone alarm restored: 3'} |
|
|
# {'data': 'Disarmed: Partition 1'} |
|
|
# {'data': 'Disarmed: Partition 2'} |
|
|
|
|
|
auth_token = request.META.get('HTTP_AUTHORIZATION', '') |
|
|
if secrets.ALARM_API_TOKEN and auth_token != 'Bearer ' + secrets.ALARM_API_TOKEN: |
|
|
raise exceptions.PermissionDenied() |
|
|
|
|
|
try: |
|
|
data = str(request.data['data']) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(data='This field is required.')) |
|
|
|
|
|
logging.info('Alarm data: %s', data) |
|
|
|
|
|
alarm = None |
|
|
if data.startswith('Armed'): |
|
|
alarm = 'Armed' |
|
|
elif data.startswith('Disarmed'): |
|
|
alarm = 'Disarmed' |
|
|
elif data.startswith('Exit delay in progress'): |
|
|
alarm = 'Exit delay' |
|
|
elif data.startswith('Alarm'): |
|
|
alarm = 'TRIGGERED!' |
|
|
|
|
|
if alarm: |
|
|
logging.info('Settings alarm status to: %s', alarm) |
|
|
cache.set('alarm', alarm) |
|
|
|
|
|
return Response(200) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def track(self, request): |
|
|
if 'name' not in request.data: |
|
|
raise exceptions.ValidationError(dict(name='This field is required.')) |
|
|
|
|
|
if 'username' not in request.data: |
|
|
raise exceptions.ValidationError(dict(username='This field is required.')) |
|
|
|
|
|
track = cache.get('track', {}) |
|
|
|
|
|
devicename = request.data['name'] |
|
|
username = request.data['username'].lower() |
|
|
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) |
|
|
|
|
|
# TODO: keep track of last report to ensure PS internet didn't cut out |
|
|
@action(detail=False, methods=['post']) |
|
|
def usage(self, request): |
|
|
if 'device' not in request.data: |
|
|
raise exceptions.ValidationError(dict(device='This field is required.')) |
|
|
|
|
|
device = request.data['device'] |
|
|
data = request.data.get('data', None) |
|
|
|
|
|
username_isfrom_track = False |
|
|
|
|
|
if 'username' in request.data: |
|
|
username = request.data['username'] |
|
|
else: |
|
|
track = cache.get('track', {}) |
|
|
try: |
|
|
username = track[device]['username'] |
|
|
username_isfrom_track = True |
|
|
except KeyError: |
|
|
msg = 'Usage tracker problem finding username for device: {}'.format(device) |
|
|
utils.alert_tanner(msg) |
|
|
logger.error(msg) |
|
|
username = '' |
|
|
|
|
|
logging.debug('Device %s data: %s', device, data) |
|
|
|
|
|
if device == 'TROTECS300' and data and int(data) > 3: |
|
|
should_count = True |
|
|
else: |
|
|
should_count = False |
|
|
|
|
|
last_use = models.Usage.objects.filter( |
|
|
device=device, |
|
|
deleted_at__isnull=True, |
|
|
).last() |
|
|
|
|
|
if should_count: |
|
|
start_new_use = not last_use or last_use.finished_at or last_use.username != username |
|
|
if start_new_use: |
|
|
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) |
|
|
username = '' |
|
|
|
|
|
try: |
|
|
user = User.objects.get(username__iexact=username) |
|
|
except User.DoesNotExist: |
|
|
msg = 'Usage tracker problem finding user for username: {}'.format(username or '[no username]') |
|
|
utils.alert_tanner(msg) |
|
|
logger.error(msg) |
|
|
user = None |
|
|
|
|
|
last_use = models.Usage.objects.create( |
|
|
user=user, |
|
|
username=username, |
|
|
device=device, |
|
|
num_reports=0, |
|
|
memo='', |
|
|
finished_at=None, |
|
|
num_seconds=0, |
|
|
) |
|
|
logging.info('New %s usage #%s created for: %s', device, last_use.id, username or '[no username]') |
|
|
else: |
|
|
if last_use and not last_use.finished_at: |
|
|
time_now = now() |
|
|
duration = time_now - last_use.started_at |
|
|
logging.info('Finishing %s usage #%s, duration: %s', device, last_use.id, duration) |
|
|
last_use.finished_at = time_now |
|
|
last_use.num_seconds = duration.seconds |
|
|
last_use.save() |
|
|
|
|
|
return Response(200) |
|
|
|
|
|
@action(detail=False, methods=['get']) |
|
|
def usage_data(self, request): |
|
|
if 'device' not in request.query_params: |
|
|
raise exceptions.ValidationError(dict(device='This field is required.')) |
|
|
|
|
|
if not ( |
|
|
is_admin_director(self.request.user) or |
|
|
utils.is_request_from_protospace(request) |
|
|
): |
|
|
raise exceptions.PermissionDenied() |
|
|
|
|
|
device = request.query_params['device'] |
|
|
device_uses = models.Usage.objects.filter(device=device) |
|
|
|
|
|
last_use = device_uses.last() |
|
|
|
|
|
if not last_use: |
|
|
raise exceptions.ValidationError(dict(device='Session not found.')) |
|
|
|
|
|
last_use_id = last_use.id |
|
|
user = last_use.user |
|
|
|
|
|
last_use_different_user = device_uses.exclude( |
|
|
user=user, |
|
|
).last() |
|
|
|
|
|
if last_use_different_user: |
|
|
last_different_id = last_use_different_user.id |
|
|
else: |
|
|
last_different_id = -1 |
|
|
|
|
|
session_uses = device_uses.filter(id__gt=last_different_id) |
|
|
|
|
|
time_now = now() |
|
|
session_time = (time_now - session_uses.first().started_at).seconds |
|
|
|
|
|
if last_use.finished_at: |
|
|
last_use_time = last_use.num_seconds |
|
|
running_cut_time = 0 |
|
|
else: |
|
|
last_use_time = (time_now - last_use.started_at).seconds |
|
|
running_cut_time = last_use_time |
|
|
|
|
|
today_start = utils.now_alberta_tz().replace(hour=0, minute=0, second=0) |
|
|
month_start = today_start.replace(day=1) |
|
|
|
|
|
today_total = device_uses.filter( |
|
|
user=user, started_at__gte=today_start, should_bill=True, |
|
|
).aggregate(Sum('num_seconds'))['num_seconds__sum'] or 0 |
|
|
|
|
|
month_total = device_uses.filter( |
|
|
user=user, started_at__gte=month_start, should_bill=True, |
|
|
).aggregate(Sum('num_seconds'))['num_seconds__sum'] or 0 |
|
|
|
|
|
today_total += running_cut_time |
|
|
month_total += running_cut_time |
|
|
|
|
|
try: |
|
|
track = cache.get('track', {})[device] |
|
|
except KeyError: |
|
|
track = False |
|
|
|
|
|
if last_use.user: |
|
|
username = last_use.user.username |
|
|
first_name = last_use.user.member.preferred_name |
|
|
else: |
|
|
username = last_use.username |
|
|
first_name = username.split('.')[0].title() |
|
|
|
|
|
return Response(dict( |
|
|
username=username, |
|
|
first_name=first_name, |
|
|
track=track, |
|
|
session_time=session_time, |
|
|
last_use_time=last_use_time, |
|
|
last_use_id=last_use_id, |
|
|
today_total=today_total, |
|
|
month_total=month_total, |
|
|
)) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def autoscan(self, request): |
|
|
if 'autoscan' not in request.data: |
|
|
raise exceptions.ValidationError(dict(autoscan='This field is required.')) |
|
|
|
|
|
cache.set('autoscan', request.data['autoscan']) |
|
|
return Response(200) |
|
|
|
|
|
@action(detail=False, methods=['post']) |
|
|
def garden(self, request): |
|
|
if 'photo' not in request.data: |
|
|
raise exceptions.ValidationError(dict(photo='This field is required.')) |
|
|
|
|
|
photo = request.data['photo'] |
|
|
medium, large = utils.process_garden_image(photo) |
|
|
|
|
|
logging.debug('Wrote garden images to %s and %s', medium, large) |
|
|
|
|
|
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 |
|
|
queryset = models.StatsMemberCount.objects.all() |
|
|
serializer_class = serializers.MemberCountSerializer |
|
|
|
|
|
class SignupCountViewSet(Base, List): |
|
|
pagination_class = None |
|
|
serializer_class = serializers.SignupCountSerializer |
|
|
|
|
|
def get_queryset(self): |
|
|
# have to use method as slicing breaks makemigrations |
|
|
return models.StatsSignupCount.objects.order_by('-month')[:16][::-1] |
|
|
|
|
|
class SpaceActivityViewSet(Base, List): |
|
|
pagination_class = None |
|
|
queryset = models.StatsSpaceActivity.objects.all() |
|
|
serializer_class = serializers.SpaceActivitySerializer |
|
|
|
|
|
|
|
|
class BackupView(views.APIView): |
|
|
def get(self, request): |
|
|
auth_token = request.META.get('HTTP_AUTHORIZATION', '') |
|
|
auth_token = auth_token.replace('Bearer ', '') |
|
|
|
|
|
backup_user = secrets.BACKUP_TOKENS.get(auth_token, None) |
|
|
|
|
|
if backup_user: |
|
|
logger.info('Backup - User: ' + backup_user['name']) |
|
|
backup_path = cache.get(backup_user['cache_key'], None) |
|
|
|
|
|
if not backup_path: |
|
|
logger.error('Backup not found') |
|
|
raise Http404 |
|
|
|
|
|
if str(now().date()) not in backup_path: |
|
|
# sanity check - make sure it's actually today's backup |
|
|
msg = 'Today\'s backup not ready yet' |
|
|
logger.error(msg) |
|
|
return Response(msg, status=503) |
|
|
|
|
|
backup_url = 'https://static.{}/backups/{}'.format( |
|
|
settings.PRODUCTION_HOST, |
|
|
backup_path, |
|
|
) |
|
|
cache.set(backup_user['name'], datetime.datetime.now()) |
|
|
|
|
|
return redirect(backup_url) |
|
|
elif auth_token: |
|
|
raise exceptions.PermissionDenied() |
|
|
else: |
|
|
backup_stats = [] |
|
|
for backup_user in secrets.BACKUP_TOKENS.values(): |
|
|
download_time = cache.get(backup_user['name'], None) |
|
|
if download_time: |
|
|
time_delta = datetime.datetime.now() - download_time |
|
|
less_than_24h = bool(time_delta.days == 0) |
|
|
else: |
|
|
less_than_24h = False |
|
|
backup_stats.append(dict( |
|
|
backup_user=backup_user['name'], |
|
|
download_time=download_time, |
|
|
less_than_24h=less_than_24h, |
|
|
)) |
|
|
return Response(backup_stats) |
|
|
|
|
|
|
|
|
class PasteView(views.APIView): |
|
|
permission_classes = [IsAuthenticatedOrReadOnly] |
|
|
|
|
|
def get(self, request): |
|
|
if request.user.id == 9: |
|
|
key = 'special_paste' |
|
|
logging.info('Using special paste for a special someone.') |
|
|
else: |
|
|
key = 'paste' |
|
|
|
|
|
return Response(dict(paste=cache.get(key, ''))) |
|
|
|
|
|
def post(self, request): |
|
|
if 'paste' in request.data: |
|
|
if request.user.id == 9: |
|
|
key = 'special_paste' |
|
|
logging.info('Using special paste for a special someone.') |
|
|
else: |
|
|
key = 'paste' |
|
|
cache.set(key, request.data['paste'][:20000]) |
|
|
return Response(dict(paste=cache.get(key, ''))) |
|
|
else: |
|
|
raise exceptions.ValidationError(dict(paste='This field is required.')) |
|
|
|
|
|
|
|
|
class HistoryViewSet(Base, List, Retrieve): |
|
|
permission_classes = [AllowMetadata | IsAdmin] |
|
|
serializer_class = serializers.HistorySerializer |
|
|
|
|
|
def get_queryset(self): |
|
|
queryset = models.HistoryIndex.objects |
|
|
|
|
|
if 'exclude_system' in self.request.query_params: |
|
|
queryset = queryset.filter(is_system=False) |
|
|
|
|
|
return queryset.order_by('-history_date')[:50] |
|
|
|
|
|
|
|
|
class VettingViewSet(Base, List): |
|
|
permission_classes = [AllowMetadata | IsAdmin] |
|
|
serializer_class = serializers.AdminMemberSerializer |
|
|
|
|
|
def get_queryset(self): |
|
|
queryset = models.Member.objects |
|
|
|
|
|
four_weeks_ago = utils.today_alberta_tz() - datetime.timedelta(days=28) |
|
|
queryset = queryset.filter(status__in=['Prepaid', 'Current', 'Due']) |
|
|
queryset = queryset.filter(paused_date__isnull=True) |
|
|
queryset = queryset.filter(vetted_date__isnull=True) |
|
|
queryset = queryset.filter(current_start_date__lte=four_weeks_ago) |
|
|
|
|
|
return queryset.order_by('-current_start_date', '-id') |
|
|
|
|
|
|
|
|
class UsageViewSet(Base): |
|
|
permission_classes = [AllowMetadata | IsAdmin] |
|
|
|
|
|
# TODO: add filtering by device |
|
|
@action(detail=False, methods=['get']) |
|
|
def csv(self, request): |
|
|
usages = models.Usage.objects.order_by('id').filter(should_bill=True) |
|
|
|
|
|
month = self.request.query_params.get('month', None) |
|
|
if month: |
|
|
try: |
|
|
dt = datetime.datetime.strptime(month, '%Y-%m') |
|
|
dt = utils.TIMEZONE_CALGARY.localize(dt) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(month='Should be YYYY-MM.')) |
|
|
|
|
|
usages = usages.filter( |
|
|
started_at__gte=dt, |
|
|
started_at__lt=dt + relativedelta.relativedelta(months=1), |
|
|
) |
|
|
|
|
|
response = HttpResponse( |
|
|
content_type='text/csv', |
|
|
) |
|
|
response['Content-Disposition'] = 'attachment; filename="usage-{}.csv"'.format(month or 'all') |
|
|
|
|
|
fieldnames = ['id', 'user__username', 'device', 'started_at', 'finished_at', 'num_seconds'] |
|
|
writer = csv.DictWriter(response, fieldnames=fieldnames) |
|
|
|
|
|
writer.writeheader() |
|
|
for u in usages.values(*fieldnames): |
|
|
u['started_at'] = u['started_at'].astimezone(utils.TIMEZONE_CALGARY) |
|
|
if u['finished_at']: |
|
|
u['finished_at'] = u['finished_at'].astimezone(utils.TIMEZONE_CALGARY) |
|
|
writer.writerow(u) |
|
|
|
|
|
return response |
|
|
|
|
|
|
|
|
class InterestViewSet(Base, Retrieve, Create): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated] |
|
|
queryset = models.Interest.objects.all() |
|
|
serializer_class = serializers.InterestSerializer |
|
|
|
|
|
def perform_create(self, serializer): |
|
|
user = self.request.user |
|
|
course = self.request.data['course'] |
|
|
|
|
|
interest = models.Interest.objects.filter(user=user, course=course, satisfied_by__isnull=True) |
|
|
if interest.exists(): |
|
|
raise exceptions.ValidationError(dict(non_field_errors='Already interested')) |
|
|
|
|
|
serializer.save( |
|
|
user=user, |
|
|
satisfied_by=None |
|
|
) |
|
|
|
|
|
|
|
|
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: |
|
|
with transaction.atomic(): |
|
|
source_user = self.request.user |
|
|
source_member = source_user.member |
|
|
|
|
|
try: |
|
|
member_id = int(request.data['member_id']) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(member_id='This field is required.')) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(member_id='Invalid number.')) |
|
|
|
|
|
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.')) |
|
|
|
|
|
# also prevents negative spending |
|
|
if amount < 1.00: |
|
|
raise exceptions.ValidationError(dict(amount='Amount too small.')) |
|
|
|
|
|
|
|
|
if member_id == source_member.id: |
|
|
raise exceptions.ValidationError(dict(member_id='Unable to send to self.')) |
|
|
|
|
|
destination_member = get_object_or_404(models.Member, id=member_id) |
|
|
destination_user = destination_member.user |
|
|
|
|
|
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.')) |
|
|
|
|
|
source_delta = -amount |
|
|
destination_delta = amount |
|
|
|
|
|
memo = 'Protocoin - Transaction {} ({}) sent ₱ {} to {} ({})'.format( |
|
|
source_member.preferred_name + ' ' + source_member.last_name, |
|
|
source_member.id, |
|
|
amount, |
|
|
destination_member.preferred_name + ' ' + destination_member.last_name, |
|
|
destination_member.id, |
|
|
) |
|
|
|
|
|
tx = models.Transaction.objects.create( |
|
|
user=source_user, |
|
|
protocoin=source_delta, |
|
|
amount=0, |
|
|
number_of_membership_months=0, |
|
|
account_type='Protocoin', |
|
|
category='Other', |
|
|
info_source='System', |
|
|
memo=memo, |
|
|
) |
|
|
utils.log_transaction(tx) |
|
|
|
|
|
tx = models.Transaction.objects.create( |
|
|
user=destination_user, |
|
|
protocoin=destination_delta, |
|
|
amount=0, |
|
|
number_of_membership_months=0, |
|
|
account_type='Protocoin', |
|
|
category='Other', |
|
|
info_source='System', |
|
|
memo=memo, |
|
|
) |
|
|
utils.log_transaction(tx) |
|
|
|
|
|
return Response(200) |
|
|
except OperationalError: |
|
|
self.send_to_member(request) |
|
|
|
|
|
@action(detail=True, methods=['get']) |
|
|
def card_vend_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() |
|
|
|
|
|
source_card = get_object_or_404(models.Card, card_number=pk) |
|
|
source_user = source_card.user |
|
|
|
|
|
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=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: |
|
|
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() |
|
|
|
|
|
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: |
|
|
raise exceptions.ValidationError(dict(number='This field is required.')) |
|
|
|
|
|
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.')) |
|
|
|
|
|
# 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.')) |
|
|
|
|
|
source_delta = -amount |
|
|
|
|
|
memo = 'Protocoin - Purchase spent ₱ {} on {} vending machine item #{}'.format( |
|
|
amount, |
|
|
machine, |
|
|
number, |
|
|
) |
|
|
|
|
|
tx = models.Transaction.objects.create( |
|
|
user=source_user, |
|
|
protocoin=source_delta, |
|
|
amount=0, |
|
|
number_of_membership_months=0, |
|
|
account_type='Protocoin', |
|
|
category='Snacks', |
|
|
info_source='System', |
|
|
memo=memo, |
|
|
) |
|
|
utils.log_transaction(tx) |
|
|
|
|
|
return Response(200) |
|
|
except OperationalError: |
|
|
self.card_vend_request(request, pk) |
|
|
|
|
|
@action(detail=False, methods=['get']) |
|
|
def transactions(self, request): |
|
|
transactions = models.Transaction.objects.exclude(protocoin=0).order_by('-date', '-id') |
|
|
total_protocoin = transactions.aggregate(Sum('protocoin'))['protocoin__sum'] or 0 |
|
|
|
|
|
serializer = serializers.ProtocoinTransactionSerializer(transactions, many=True) |
|
|
|
|
|
res = dict( |
|
|
total_protocoin=total_protocoin, |
|
|
transactions=serializer.data, |
|
|
) |
|
|
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.PRINTER_API_TOKEN and auth_token != 'Bearer ' + secrets.PRINTER_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']) |
|
|
def score(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() |
|
|
|
|
|
card_number = request.data.get('card_number', None) |
|
|
|
|
|
if card_number: |
|
|
card = get_object_or_404(models.Card, card_number=card_number) |
|
|
user = card.user |
|
|
else: |
|
|
user = None |
|
|
|
|
|
try: |
|
|
game_id = int(request.data['game_id']) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(game_id='This field is required.')) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(game_id='Invalid number.')) |
|
|
|
|
|
try: |
|
|
player = int(request.data['player']) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(player='This field is required.')) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(player='Invalid number.')) |
|
|
|
|
|
try: |
|
|
score = int(request.data['score']) |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(score='This field is required.')) |
|
|
except ValueError: |
|
|
raise exceptions.ValidationError(dict(score='Invalid number.')) |
|
|
|
|
|
_ = models.PinballScore.objects.update_or_create( |
|
|
game_id=game_id, |
|
|
player=player, |
|
|
defaults=dict( |
|
|
user=user, |
|
|
score=score, |
|
|
finished_at=now(), |
|
|
), |
|
|
) |
|
|
|
|
|
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) |
|
|
|
|
|
try: # TODO: remove try / except |
|
|
# send a message to Spacebar |
|
|
message = 'A member just offered to host for {} hours from now until {}!'.format( |
|
|
hours, |
|
|
h.finished_at.astimezone(utils.TIMEZONE_CALGARY).strftime('%-I:%M %p'), |
|
|
) |
|
|
if hosting_user.member.discourse_username: |
|
|
message += ' Tag @{} here to get their attention.'.format( |
|
|
hosting_user.member.discourse_username, |
|
|
) |
|
|
utils.spaceporter_host(message) |
|
|
except: |
|
|
pass |
|
|
|
|
|
# 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) |
|
|
|
|
|
@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( |
|
|
hosting_hours=Sum('user__hosting__hours', filter=Q(user__hosting__finished_at__gte=current_month_start)), |
|
|
).exclude(hosting_hours__isnull=True).order_by('-hosting_hours') |
|
|
|
|
|
scores = [] |
|
|
|
|
|
for member in members: |
|
|
scores.append(dict( |
|
|
name=member.preferred_name + ' ' + member.last_name[0], |
|
|
hours=member.hosting_hours, |
|
|
member_id=member.id, |
|
|
)) |
|
|
|
|
|
return Response(scores) |
|
|
|
|
|
|
|
|
class StorageSpaceViewSet(Base, List, Retrieve, Update): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated, IsAdminOrReadOnly] |
|
|
queryset = models.StorageSpace.objects.all().select_related('user__member').order_by('id') |
|
|
serializer_class = serializers.StorageSpaceSerializer |
|
|
|
|
|
@action(detail=False, methods=['post'], permission_classes=[AllowMetadata | IsAuthenticated]) |
|
|
def claim(self, request): |
|
|
user = self.request.user |
|
|
|
|
|
if user.storage.count(): |
|
|
raise exceptions.ValidationError(dict(shelf_id='You already have a shelf.')) |
|
|
|
|
|
try: |
|
|
shelf_id = str(request.data['shelf_id']).upper() |
|
|
except KeyError: |
|
|
raise exceptions.ValidationError(dict(shelf_id='This field is required.')) |
|
|
|
|
|
try: |
|
|
storage = models.StorageSpace.objects.get(shelf_id=shelf_id) |
|
|
except models.StorageSpace.DoesNotExist: |
|
|
raise exceptions.ValidationError(dict(shelf_id='Shelf ID not found.')) |
|
|
|
|
|
if storage.location != 'member_shelves': |
|
|
raise exceptions.ValidationError(dict(shelf_id='Not a member shelf. Please see a Director.')) |
|
|
|
|
|
if storage.user: |
|
|
owner = storage.user.member.preferred_name |
|
|
raise exceptions.ValidationError(dict(shelf_id='Shelf already belongs to {}.'.format(owner))) |
|
|
|
|
|
storage.user = user |
|
|
storage.save() |
|
|
|
|
|
return Response(200) |
|
|
|
|
|
|
|
|
class RegistrationView(RegisterView): |
|
|
serializer_class = serializers.MyRegisterSerializer |
|
|
|
|
|
class PasswordChangeView(PasswordChangeView): |
|
|
permission_classes = [AllowMetadata | IsAuthenticated] |
|
|
serializer_class = serializers.MyPasswordChangeSerializer |
|
|
|
|
|
class PasswordResetView(PasswordResetView): |
|
|
serializer_class = serializers.MyPasswordResetSerializer |
|
|
|
|
|
class PasswordResetConfirmView(PasswordResetConfirmView): |
|
|
serializer_class = serializers.MyPasswordResetConfirmSerializer |
|
|
|
|
|
class SpaceportAuthView(LoginView): |
|
|
serializer_class = serializers.SpaceportAuthSerializer |
|
|
|
|
|
class MyLoginView(LoginView): |
|
|
serializer_class = serializers.MyLoginSerializer |
|
|
|
|
|
|
|
|
@api_view() |
|
|
def null_view(request, *args, **kwargs): |
|
|
raise Http404
|
|
|
|