You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

762 lines
29 KiB

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
from django.db.utils import OperationalError
from django.http import HttpResponse, Http404, FileResponse
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
import datetime, time
import requests
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap
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_SEARCH_RESULTS = 20
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=NUM_SEARCH_RESULTS, scorer=fuzz.token_set_ratio)
results += [x[0] for x in fuzzy_results]
# remove dupes, truncate list
results = list(OrderedDict.fromkeys(results))[:NUM_SEARCH_RESULTS]
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' and sort == 'recently_vetted':
utils.gen_search_strings() # update cache
queryset = queryset.order_by('-vetted_date')
elif self.action == 'create' and sort == 'newest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'newest_overall':
queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'oldest_active':
queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'oldest_overall':
queryset = queryset.filter(application_date__isnull=False)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'recently_inactive':
queryset = queryset.filter(paused_date__isnull=False)
queryset = queryset.order_by('-paused_date')
elif self.action == 'create' and sort == 'is_director':
queryset = queryset.filter(is_director=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'is_instructor':
queryset = queryset.filter(is_instructor=True)
queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'due':
queryset = queryset.filter(status='Due')
queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'overdue':
queryset = queryset.filter(status='Overdue')
queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'last_scanned':
if self.request.user.member.allow_last_scanned:
queryset = queryset.filter(allow_last_scanned=True)
queryset = queryset.order_by('-user__cards__last_seen_at')
else:
queryset = []
elif self.action == 'create' and 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()
if search:
num_results = NUM_SEARCH_RESULTS
else:
num_results = 100
queryset = self.get_queryset()[: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})
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_create(self, serializer):
member = serializer.save()
utils.tally_membership_months(member)
utils.gen_member_forms(member)
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 = 'Former Member'
member.paused_date = utils.today_alberta_tz()
member.save()
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()
member = self.get_object()
member.current_start_date = utils.today_alberta_tz()
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, Destroy):
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()
class CourseViewSet(Base, List, Retrieve, Create, Update):
permission_classes = [AllowMetadata | IsAuthenticated, IsAdminOrReadOnly | IsInstructorOrReadOnly]
queryset = models.Course.objects.annotate(date=Max('sessions__datetime')).order_by('-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 | IsAuthenticated, IsAdminOrReadOnly | IsInstructorOrReadOnly]
def get_queryset(self):
if self.action == 'list':
return models.Session.objects.order_by('-datetime')[:20]
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):
serializer.save(instructor=self.request.user)
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):
# always update cert date incase member is returning and gets recertified
if session.course.id == 249:
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 261:
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 401:
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 281:
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 283:
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 259:
member.tormach_cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 428:
member.precix_cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
if utils_ldap.is_configured():
if status == 'Attended':
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 = utils.today_alberta_tz() if status == 'Attended' else None
if utils_ldap.is_configured():
if status == 'Attended':
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 = utils.today_alberta_tz() if status == 'Attended' else None
if utils_ldap.is_configured():
if status == 'Attended':
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('Not allowed to register others')
member = get_object_or_404(models.Member, id=data['member_id'])
user = getattr(member, 'user', None)
training1 = models.Training.objects.filter(user=user, session=session)
training2 = models.Training.objects.filter(member_id=member.id, session=session)
if (user and training1.exists()) or training2.exists():
raise exceptions.ValidationError(dict(non_field_errors='Already registered.'))
self.update_cert(session, member, status)
serializer.save(user=user, member_id=member.id, attendance_status=status)
else:
training = models.Training.objects.filter(user=user, session=session)
if training.exists():
raise exceptions.ValidationError('Already registered')
if user == session.instructor:
raise exceptions.ValidationError('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)
if training.user:
member = training.user.member
else:
member = models.Member.objects.get(id=training.member_id)
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', '')
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='')
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 and tx.member_id:
models.PayPalHint.objects.update_or_create(
account=tx.paypal_payer_id,
defaults=dict(member_id=tx.member_id),
)
def perform_create(self, serializer):
serializer.save(recorder=self.request.user)
self.retally_membership()
def perform_update(self, serializer):
tx = serializer.save()
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=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)
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):
d = request.data.dict()
if d:
logger.info(str(d))
return Response(200)
class DoorViewSet(viewsets.ViewSet, List):
def list(self, request):
auth_token = request.META.get('HTTP_AUTHORIZATION', '')
if 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:
try:
member = models.Member.objects.get(id=card.member_id)
except models.Member.DoesNotExist:
continue
if member.paused_date: continue
if not member.is_allowed_entry: continue
active_member_cards[card.card_number] = '{} ({})'.format(
member.first_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_at = utils.today_alberta_tz()
card.save()
try:
member = models.Member.objects.get(id=card.member_id)
except models.Member.DoesNotExist:
raise Http404
t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p')
logger.info('Time: {} - Name: {} {} ({})'.format(t, member.first_name, member.last_name, member.id))
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 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:
try:
member = models.Member.objects.get(id=card.member_id)
except models.Member.DoesNotExist:
continue
if member.paused_date: continue
if not member.is_allowed_entry: continue
authorization = {}
authorization['id'] = member.id
authorization['name'] = member.first_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)))
finally:
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['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 alarm(self, request):
try:
alarm = dict(time=time.time(), data=int(request.data['data']))
cache.set('alarm', alarm)
return Response(200)
except ValueError:
raise exceptions.ValidationError(dict(data='Invalid integer.'))
except KeyError:
raise exceptions.ValidationError(dict(data='This field is required.'))
@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']
first_name = username.split('.')[0].title()
track[devicename] = dict(time=time.time(), username=first_name)
cache.set('track', track)
## update device usage
## issue: sometimes two sessions are created
## issue: sometimes two /track/ requests are sent and double time is counted
#last_session = models.UsageTrack.objects.filter(devicename=devicename).last()
#if not last_session or last_session.username != username:
# try:
# user = User.objects.get(username__iexact=username)
# except User.DoesNotExist:
# msg = 'Device tracker problem finding username: ' + username
# utils.alert_tanner(msg)
# logger.error(msg)
# user = None
# models.UsageTrack.objects.create(
# user=user,
# username=username,
# devicename=devicename,
# num_seconds=0,
# )
# logging.info('New ' + devicename + ' session created for: ' + username)
#else:
# last_session.num_seconds = F('num_seconds') + 10
# last_session.save(update_fields=['num_seconds'])
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):
return Response(dict(paste=cache.get('paste', '')))
def post(self, request):
if 'paste' in request.data:
cache.set('paste', request.data['paste'][:20000])
return Response(dict(paste=cache.get('paste', '')))
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')
class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer
def post(self, request):
data = request.data.copy()
data.pop('password1', None)
data.pop('password2', None)
logger.info(dict(data))
return super().post(request)
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
@api_view()
def null_view(request, *args, **kwargs):
raise Http404