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 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_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') elif sort == 'newest_active': queryset = queryset.filter(paused_date__isnull=True) queryset = queryset.order_by('-application_date') elif sort == 'newest_overall': queryset = queryset.order_by('-application_date') elif sort == 'oldest_active': queryset = queryset.filter(paused_date__isnull=True) queryset = queryset.order_by('application_date') elif sort == 'oldest_overall': queryset = queryset.filter(application_date__isnull=False) queryset = queryset.order_by('application_date') 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') elif sort == 'is_instructor': queryset = queryset.filter(is_instructor=True) queryset = queryset.order_by('application_date') elif sort == 'due': queryset = queryset.filter(status='Due') queryset = queryset.order_by('expire_date') elif sort == 'overdue': queryset = queryset.filter(status='Overdue') queryset = queryset.order_by('expire_date') elif 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') else: queryset = [] elif sort == 'everyone': queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count') 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_create(self, serializer): member = serializer.save() utils.tally_membership_months(member) utils.gen_member_forms(member) utils.gen_search_strings() def perform_update(self, serializer): member = serializer.save() utils.tally_membership_months(member) utils.gen_member_forms(member) utils.gen_search_strings() @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 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: 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 = now() 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 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: 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