• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

WPI-LNL / lnldb / 10155059950

30 Jul 2024 02:22AM UTC coverage: 90.975% (-0.1%) from 91.083%
10155059950

Pull #853

github

web-flow
Merge 63b26c840 into 6384c05c8
Pull Request #853: Upgrade to Django 4.2 (Big Django Update)

99 of 115 new or added lines in 42 files covered. (86.09%)

38 existing lines in 8 files now uncovered.

15000 of 16488 relevant lines covered (90.98%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.96
/api/views.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
1✔
3

4
from django.shortcuts import render, reverse
1✔
5
from django.db import IntegrityError, transaction
1✔
6
from django.db.models import Q
1✔
7
from django.conf import settings
1✔
8
from django.contrib.auth import get_user_model
1✔
9
from django.contrib.auth.decorators import login_required
1✔
10
from django.core.exceptions import ValidationError
1✔
11
from django.template.loader import render_to_string
1✔
12
from django.shortcuts import get_object_or_404
1✔
13
from django.http import HttpResponseRedirect
1✔
14
from django.utils import timezone
1✔
15
from rest_framework import viewsets, status, serializers
1✔
16
from rest_framework.response import Response
1✔
17
from rest_framework.decorators import api_view, authentication_classes, action
1✔
18
from rest_framework.authentication import TokenAuthentication
1✔
19
from rest_framework.permissions import IsAuthenticated
1✔
20
from rest_framework.authtoken.models import Token
1✔
21
from rest_framework.exceptions import NotFound, ParseError, AuthenticationFailed, PermissionDenied, NotAuthenticated
1✔
22
from cryptography.fernet import Fernet
1✔
23
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter, OpenApiTypes, OpenApiResponse, \
1✔
24
    OpenApiExample, inline_serializer
25

26
from random import randint
1✔
27

28
from accounts.models import UserPreferences, Officer
1✔
29
from accounts.forms import SMSOptInForm
1✔
30
from emails.generators import generate_sms_email
1✔
31
from events.models import OfficeHour, Event2019, Location, CrewAttendanceRecord
1✔
32
from data.models import Notification, Extension, ResizedRedirect
1✔
33
from pages.models import Page
1✔
34
from spotify.models import Session, SpotifyUser, SongRequest
1✔
35
from spotify.api import play, pause, skip, previous, get_available_devices, get_track, queue_estimate
1✔
36
from .models import TokenRequest
1✔
37
from .serializers import OfficerSerializer, HourSerializer, NotificationSerializer, EventSerializer, \
1✔
38
    AttendanceSerializer, RedirectSerializer, CustomPageSerializer, SpotifySessionReadOnlySerializer, \
39
    SpotifySessionWriteSerializer, SongRequestSerializer, TokenRequestSerializer
40
from . import examples
1✔
41

42

43
# Create your views here.
44
@extend_schema_view(
1✔
45
    list=extend_schema(
46
        operation_id="Officers",
47
        parameters=[
48
            OpenApiParameter('title', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "The officer's title"),
49
            OpenApiParameter(
50
                'first_name', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "The first name of the officer"
51
            ),
52
            OpenApiParameter(
53
                'last_name', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "The last name of the officer"
54
            ),
55
            OpenApiParameter(
56
                'options', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "Additional fields to include in response",
57
                style='simple'
58
            )
59
        ],
60
        responses={
61
            200: OpenApiResponse(OfficerSerializer, description="Ok"),
62
            404: OpenApiResponse(description="Not found")
63
        }
64
    )
65
)
66
class OfficerViewSet(viewsets.ReadOnlyModelViewSet):
1✔
67
    """ Use this endpoint to get a list of the club's officers """
68
    serializer_class = OfficerSerializer
1✔
69
    authentication_classes = []
1✔
70
    lookup_field = 'title'
1✔
71

72
    def get_queryset(self):
1✔
73
        queryset = Officer.objects.filter(user__groups__name="Officer").exclude(user__isnull=True).order_by('title')
1✔
74
        title = self.request.query_params.get('title', None)
1✔
75
        first_name = self.request.query_params.get('first_name', None)
1✔
76
        last_name = self.request.query_params.get('last_name', None)
1✔
77
        if title is not None:
1✔
78
            queryset = queryset.filter(title__iexact=title)
1✔
79
        if first_name is not None:
1✔
80
            queryset = queryset.filter(user__first_name__iexact=first_name)
1✔
81
        if last_name is not None:
1✔
82
            queryset = queryset.filter(user__last_name__iexact=last_name)
1✔
83
        if queryset.count() == 0:
1✔
84
            raise NotFound(detail='Not found', code=404)
1✔
85
        return queryset
1✔
86

87

88
class HourViewSet(viewsets.ReadOnlyModelViewSet):
1✔
89
    """ Use this endpoint to get a list of our office hours """
90
    serializer_class = HourSerializer
1✔
91
    authentication_classes = []
1✔
92

93
    @extend_schema(
1✔
94
        operation_id="Office Hours",
95
        parameters=[
96
            OpenApiParameter("officer", OpenApiTypes.STR, OpenApiParameter.QUERY, False, "The officer's title"),
97
            OpenApiParameter(
98
                "day", OpenApiTypes.INT, OpenApiParameter.QUERY, False,
99
                "The day of the week (0 = Sunday, 1 = Monday, etc.)", list(range(7))
100
            ),
101
            OpenApiParameter("start", OpenApiTypes.TIME, OpenApiParameter.QUERY, False, "The start time"),
102
            OpenApiParameter("end", OpenApiTypes.TIME, OpenApiParameter.QUERY, False, "The end time")
103
        ],
104
        responses={
105
            200: OpenApiResponse(HourSerializer),
106
            204: None,
107
            400: OpenApiResponse(description="Bad request")
108
        },
109
        examples=[
110
            OpenApiExample(
111
                'Example with start and end time',
112
                [{'officer': 'President', 'day': 4, 'hour_start': '13:30:00', 'hour_end': '15:30:00'}],
113
                description='If both "start" and "end" are included in the request, the response will contain entries '
114
                            'that overlap with that time span.',
115
                summary="/api/v1/office-hours?start=13:00&end=14:00",
116
                response_only=True
117
            )
118
        ]
119
    )
120
    def list(self, request):
1✔
121
        try:
1✔
122
            queryset = self.get_queryset()
1✔
123
        except ValidationError:
×
124
            content = {
×
125
                '400': 'Bad request. Please ensure that you have supplied valid start and end times, if applicable.'
126
            }
127
            return Response(content, status=status.HTTP_400_BAD_REQUEST)
×
128
        if queryset.count() == 0:
1✔
129
            content = {'204': 'No entries were found for the specified parameters'}
1✔
130
            return Response(content, status=status.HTTP_204_NO_CONTENT)
1✔
131
        serializer = HourSerializer(queryset, many=True)
1✔
132
        return Response(serializer.data)
1✔
133

134
    def get_queryset(self):
1✔
135
        queryset = OfficeHour.objects.all().order_by('day', 'hour_start')
1✔
136
        officer = self.request.query_params.get('officer', None)
1✔
137
        day = self.request.query_params.get('day', None)
1✔
138
        location = self.request.query_params.get('location', None)
1✔
139
        start = self.request.query_params.get('start', None)
1✔
140
        end = self.request.query_params.get('end', None)
1✔
141
        if officer is not None:
1✔
142
            queryset = queryset.filter(officer__exec_position__title__iexact=officer)
1✔
143
        if day is not None:
1✔
144
            queryset = queryset.filter(day=day)
1✔
145
        if location is not None:
1✔
146
            queryset = queryset.filter(location__name__icontains=location)
1✔
147
        if start is not None and end is not None:
1✔
148
            queryset = queryset.filter(hour_start__lte=end).filter(hour_end__gte=start)
1✔
149
        if start is not None and end is None:
1✔
150
            queryset = queryset.filter(hour_start=start)
1✔
151
        if end is not None and start is None:
1✔
152
            queryset = queryset.filter(hour_end=end)
1✔
153
        return queryset
1✔
154

155

156
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
1✔
157
    """ Use this endpoint to fetch notifications for the website """
158
    authentication_classes = []
1✔
159

160
    @extend_schema(
1✔
161
        operation_id="Site Notifications",
162
        parameters=[
163
            OpenApiParameter(
164
                'project_id', OpenApiTypes.STR, OpenApiParameter.QUERY, True,
165
                "Unique identifier for the application accessing the endpoint"
166
            ),
167
            OpenApiParameter(
168
                'page_id', OpenApiTypes.STR, OpenApiParameter.QUERY, True,
169
                "Unique identifier assigned to the calling view"
170
            ),
171
            OpenApiParameter(
172
                'directory', OpenApiTypes.STR, OpenApiParameter.QUERY, False,
173
                "Parent directory of the calling view (if applicable). Subscribes the page to directory notifications."
174
            ),
175
        ],
176
        responses={
177
            200: inline_serializer(
178
                'Notification',
179
                fields={
180
                    'id': serializers.CharField(),
181
                    'class': serializers.IntegerField(),
182
                    'format': serializers.CharField(),
183
                    'type': serializers.CharField(),
184
                    'expires': serializers.DateTimeField(),
185
                    'title': serializers.CharField(),
186
                    'message': serializers.CharField()
187
                }
188
            ),
189
            204: None
190
        },
191
        examples=[
192
            OpenApiExample(
193
                'Notification', examples.notification_response,
194
                description=render_to_string('api/notification_response.html').strip(),
195
                response_only=True
196
            )
197
        ]
198
    )
199
    def list(self, request):
1✔
200
        queryset = self.get_queryset()
1✔
201
        if queryset.count() == 0:
1✔
202
            content = {'204': 'No alerts were found for the specified parameters'}
1✔
203
            return Response(content, status=status.HTTP_204_NO_CONTENT)
1✔
204
        serializer = NotificationSerializer(queryset, many=True)
1✔
205
        return Response(serializer.data)
1✔
206

207
    def get_queryset(self):
1✔
208
        queryset = Notification.objects.filter(target__iexact='all')
1✔
209
        project = self.request.query_params.get('project_id', None)
1✔
210
        page = self.request.query_params.get('page_id', None)
1✔
211
        dirs = self.request.query_params.get('directory', None)
1✔
212
        if project is None or project != "LNL":
1✔
213
            raise NotFound(detail='Invalid project id', code=404)
1✔
214
        if page is None:
1✔
215
            raise ParseError(detail='Page ID is required', code=400)
1✔
216
        queryset |= Notification.objects.filter(target=page)
1✔
217
        if dirs not in [None, '']:
1✔
218
            directories = dirs.split('/')
1✔
219
            queryset |= Notification.objects.filter(target__in=directories)
1✔
220
        return queryset
1✔
221

222

223
class EventViewSet(viewsets.ReadOnlyModelViewSet):
1✔
224
    """ Use this endpoint to retrieve event objects """
225
    serializer_class = EventSerializer
1✔
226
    authentication_classes = []
1✔
227

228
    # For now, do not accept new event submissions through the API (the workorder tool already does that well)
229
    @extend_schema(
1✔
230
        operation_id="Events",
231
        parameters=[
232
            OpenApiParameter('id', OpenApiTypes.INT, OpenApiParameter.QUERY, False, "Retrieve an event by its pk value"),
233
            OpenApiParameter('name', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "Filter by event name"),
234
            OpenApiParameter('location', OpenApiTypes.STR, OpenApiParameter.QUERY, False, "Filter by location"),
235
            OpenApiParameter('start', OpenApiTypes.DATETIME, OpenApiParameter.QUERY, False, "Filter by start time"),
236
            OpenApiParameter('end', OpenApiTypes.DATETIME, OpenApiParameter.QUERY, False, "Filter by end time"),
237
        ],
238
        responses={
239
            200: EventSerializer,
240
            204: None
241
        },
242
        examples=[
243
            OpenApiExample(
244
                'Event',
245
                [{"id": 21, "event_name": "Test Event", "description": "A test event", "location": "Library",
246
                  "datetime_start": "2021-01-01T04:00:00.000Z", "datetime_end": "2021-01-01T06:00:00.000Z"}],
247
                description='If both "start" and "end" are provided, only events that fall fully within the specified '
248
                            'time span will be returned.',
249
                response_only=True
250
            )
251
        ]
252
    )
253
    def list(self, request):
1✔
254
        queryset = self.get_queryset()
1✔
255
        if queryset.count() == 0:
1✔
256
            content = {'204': 'No events could be found with the specified parameters'}
1✔
257
            return Response(content, status=status.HTTP_204_NO_CONTENT)
1✔
258
        serializer = EventSerializer(queryset, many=True)
1✔
259
        return Response(serializer.data)
1✔
260

261
    def get_queryset(self):
1✔
262
        queryset = Event2019.objects.filter(sensitive=False, test_event=False, approved=True).order_by('id')
1✔
263
        event_id = self.request.query_params.get('id', None)
1✔
264
        name = self.request.query_params.get('name', None)
1✔
265
        location_slug = self.request.query_params.get('location', None)
1✔
266
        start = self.request.query_params.get('start', None)
1✔
267
        end = self.request.query_params.get('end', None)
1✔
268
        default = True
1✔
269
        if event_id:
1✔
270
            return queryset.filter(pk=event_id)
1✔
271
        if name:
1✔
272
            default = False
1✔
273
            queryset = queryset.filter(event_name__icontains=name)
1✔
274
        if location_slug:
1✔
275
            search = Event2019.objects.none()
1✔
276
            for location in Location.objects.filter(Q(name__icontains=location_slug) |
1✔
277
                                                    Q(building__name__icontains=location_slug)):
278
                default = False
1✔
279
                search |= queryset.filter(location=location)
1✔
280
            queryset = search
1✔
281
        if start and end:
1✔
282
            default = False
1✔
283
            queryset = queryset.filter(datetime_start__lte=end, datetime_end__gte=start)
1✔
284
        elif start:
1✔
285
            default = False
1✔
286
            queryset = queryset.filter(datetime_start=start)
1✔
287
        elif end:
1✔
288
            default = False
1✔
289
            queryset = queryset.filter(datetime_end=end)
1✔
290

291
        if default is True:
1✔
292
            queryset = queryset.filter(datetime_end__gt=timezone.now(), reviewed=False, closed=False, cancelled=False)
1✔
293
        return queryset
1✔
294

295

296
class AttendanceViewSet(viewsets.ModelViewSet):
1✔
297
    serializer_class = AttendanceSerializer
1✔
298
    authentication_classes = [TokenAuthentication]
1✔
299
    permission_classes = [IsAuthenticated]
1✔
300

301
    def check_required_fields(self):
1✔
302
        student_id = self.request.data.get('id', None)
1✔
303
        event_id = self.request.data.get('event', None)
1✔
304
        if student_id in [None, ''] or event_id in [None, '']:
1✔
305
            raise ParseError(detail="Bad request. One or more required keys are missing or malformed.")
1✔
306
        user = get_object_or_404(get_user_model(), student_id=student_id)
1✔
307
        event = get_object_or_404(Event2019, pk=event_id)
1✔
308
        if not event.approved or event.datetime_end + timezone.timedelta(hours=5) <= timezone.now() or event.closed or \
1✔
309
                min(event.ccinstances.values_list('setup_start', flat=True)) > timezone.now() or event.cancelled:
310
            raise PermissionDenied(detail="This event is not currently accepting checkin / checkout requests.")
1✔
311
        return user, event
1✔
312

313
    @extend_schema(
1✔
314
        operation_id="Crew Checkin",
315
        request=inline_serializer(
316
            'Checkin Request',
317
            fields={
318
                'id': serializers.IntegerField(),
319
                'event': serializers.IntegerField(),
320
                'checkin': serializers.DateTimeField(required=False)
321
            }
322
        ),
323
        responses={
324
            201: OpenApiResponse(description="Created"),
325
            400: OpenApiResponse(description="Bad request. One or more required keys are missing or malformed."),
326
            401: OpenApiResponse(description="Unauthorized"),
327
            403: OpenApiResponse(description="Event is not accepting checkin / checkout requests at this time"),
328
            404: OpenApiResponse(description="User or event not found")
329
        }
330
    )
331
    @action(['POST'], True)
1✔
332
    def checkin(self, request):
1✔
333
        """ Use this endpoint to check a user into an event as a crew member """
334
        user, event = self.check_required_fields()
1✔
335
        time = request.data.get('checkin', None)
1✔
336
        if time in [None, '']:
1✔
337
            time = str(timezone.now().replace(second=0, microsecond=0))
1✔
338
        if event.max_crew and event.crew_attendance.filter(active=True).values('user').count() == event.max_crew:
1✔
339
            raise PermissionDenied(detail="This event has reached it's crew member or occupancy limit. Please try "
1✔
340
                                          "again later.")
341
        if CrewAttendanceRecord.objects.filter(user=user, active=True).exists():
1✔
342
            return Response({'detail': 'This user is already checked into an event.'}, status=status.HTTP_409_CONFLICT)
1✔
343
        data = {
1✔
344
            "user": user.pk,
345
            "event": event.pk,
346
            "checkin": time,
347
            "active": True
348
        }
349
        serializer = self.get_serializer(data=data)
1✔
350
        serializer.is_valid(raise_exception=True)
1✔
351
        serializer.save()
1✔
352
        if not event.hours.filter(user=user).exists():
1✔
353
            event.hours.create(user=user)
1✔
354
        return Response(status=status.HTTP_201_CREATED)
1✔
355

356
    @extend_schema(
1✔
357
        operation_id="Crew Checkout",
358
        request=inline_serializer(
359
            'Checkout Request',
360
            fields={
361
                'id': serializers.IntegerField(),
362
                'event': serializers.IntegerField(),
363
                'checkout': serializers.DateTimeField(required=False)
364
            }
365
        ),
366
        responses={
367
            200: OpenApiResponse(description="Ok"),
368
            400: OpenApiResponse(description="Bad request. One or more required keys are missing or malformed."),
369
            401: OpenApiResponse(description="Unauthorized"),
370
            403: OpenApiResponse(description="Event is not accepting checkin / checkout requests at this time"),
371
            404: OpenApiResponse(description="Could not retrieve crew record. This will be returned if the user is not "
372
                                             "checked into the specified event or the user or event does not exist.")
373
        }
374
    )
375
    @action(['POST'], True)
1✔
376
    def checkout(self, request):
1✔
377
        """ Use this endpoint to check a user out of an event """
378
        user, event = self.check_required_fields()
1✔
379
        time = request.data.get('checkout', None)
1✔
380
        if time in [None, '']:
1✔
381
            time = timezone.now().replace(second=0, microsecond=0)
1✔
382
        record = CrewAttendanceRecord.objects.filter(user=user, event=event, active=True).first()
1✔
383
        if record is None:
1✔
384
            raise NotFound(detail="It appears this user has not yet checked in for this event")
1✔
385
        data = {
1✔
386
            "user": user.pk,
387
            "event": event.pk,
388
            "checkout": time,
389
            "active": False
390
        }
391
        serializer = self.get_serializer(data=data)
1✔
392
        serializer.is_valid(raise_exception=True)
1✔
393
        serializer.update(record, serializer.validated_data)
1✔
394
        return Response(status=status.HTTP_200_OK)
1✔
395

396

397
class SitemapViewSet(viewsets.ReadOnlyModelViewSet):
1✔
398
    """ Use this endpoint to retrieve a list of custom pages and redirect links to display in our sitemap """
399
    authentication_classes = []
1✔
400

401
    @extend_schema(
1✔
402
        operation_id="Sitemap",
403
        parameters=[
404
            OpenApiParameter('type', OpenApiTypes.STR, OpenApiParameter.QUERY, False, 'Filter by link type',
405
                             ['page', 'redirect']),
406
            OpenApiParameter('category', OpenApiTypes.STR, OpenApiParameter.QUERY, False, 'Filter by sitemap category')
407
        ],
408
        responses={
409
            200: OpenApiResponse([CustomPageSerializer, RedirectSerializer], description="Ok"),
410
            204: None
411
        },
412
        examples=[
413
            OpenApiExample(
414
                'Page', [{"title": "Example Page", "path": "example", "category": "Support"}], response_only=True
415
            ),
416
            OpenApiExample(
417
                'Redirect', [{"title": "Sharepoint", "path": "drive/", "category": "Redirects"}], response_only=True,
418
                description='**Note:** Redirects cannot be filtered by category. If "category" is set, the value of '
419
                            '"type" will be ignored and pages will be returned instead.'
420
            ),
421
            OpenApiExample(
422
                'Both',
423
                [{"title": "Example Page", "path": "example", "category": "Support"},
424
                 {"title": "Sharepoint", "path": "sharepoint/", "category": "Redirects"}],
425
                description='**Note:** Redirects cannot be filtered by category. If "category" is set, the value of '
426
                            '"type" will be ignored and only pages will be returned.'
427
            )
428
        ]
429
    )
430
    def list(self, request):
1✔
431
        (pages, redirects) = self.get_queryset()
1✔
432
        if pages.count() == 0 and redirects.count() == 0:
1✔
433
            content = {'204': 'No data was found for the specified parameters'}
1✔
434
            return Response(content, status=status.HTTP_204_NO_CONTENT)
1✔
435
        page_serializer = CustomPageSerializer(pages, many=True)
1✔
436
        redirect_serializer = RedirectSerializer(redirects, many=True)
1✔
437

438
        data = page_serializer.data + redirect_serializer.data
1✔
439
        return Response(data)
1✔
440

441
    def get_queryset(self):
1✔
442
        pages = Page.objects.filter(sitemap=True)
1✔
443
        redirects = ResizedRedirect.objects.filter(sitemap=True)
1✔
444

445
        link_type = self.request.query_params.get('type', None)
1✔
446
        category = self.request.query_params.get('category', None)
1✔
447

448
        if category not in [None, '']:
1✔
449
            pages = pages.filter(sitemap_category__icontains=category)
1✔
450
            redirects = ResizedRedirect.objects.none()
1✔
451
            link_type = 'page'
1✔
452
        if link_type == 'page':
1✔
453
            return pages, ResizedRedirect.objects.none()
1✔
454
        elif link_type == 'redirect':
1✔
455
            return Page.objects.none(), redirects
1✔
456
        return pages, redirects
1✔
457

458

459
class SpotifyUserViewSet(viewsets.GenericViewSet):
1✔
460
    authentication_classes = [TokenAuthentication]
1✔
461
    permission_classes = [IsAuthenticated]
1✔
462

463
    @extend_schema(
1✔
464
        operation_id="Get Available Devices",
465
        parameters=[
466
            OpenApiParameter('id', OpenApiTypes.INT, OpenApiParameter.PATH, True, "The unique ID of the Spotify user")
467
        ],
468
        responses={
469
            200: OpenApiResponse(description="Ok"),
470
            401: OpenApiResponse(description="Unauthorized"),
471
            403: OpenApiResponse(description="Permission denied"),
472
            404: OpenApiResponse(description="Not found")
473
        }
474
    )
475
    @action(['GET'], True)
1✔
476
    def devices(self, request, pk=None):
1✔
477
        """ Use this endpoint to retrieve a list of available devices """
478

479
        account = get_object_or_404(SpotifyUser, pk=pk, token_info__isnull=False)
1✔
480

481
        if account.personal and request.user != account.user:
1✔
482
            raise PermissionDenied
×
483
        elif not account.personal and not request.user.has_perm('view_spotifyuser', account):
1✔
484
            raise PermissionDenied
1✔
485

486
        devices = get_available_devices(account)
1✔
487

488
        if not devices:
1✔
489
            return Response(None, status=status.HTTP_204_NO_CONTENT)
1✔
490

491
        return Response(devices, status=status.HTTP_200_OK)
×
492

493

494
class SpotifySessionViewSet(viewsets.GenericViewSet):
1✔
495
    authentication_classes = [TokenAuthentication]
1✔
496
    permission_classes = [IsAuthenticated]
1✔
497
    lookup_field = 'slug'
1✔
498
    lookup_url_kwarg = 'id'
1✔
499

500
    def get_serializer_class(self):
1✔
501
        if self.action in ['create', 'partial_update']:
×
502
            return SpotifySessionWriteSerializer
×
503
        else:
504
            return SpotifySessionReadOnlySerializer
×
505

506
    def get_permissions(self):
1✔
507
        if self.action in ['list', 'retrieve']:
1✔
508
            return []
1✔
509
        return super(SpotifySessionViewSet, self).get_permissions()
1✔
510

511
    def get_queryset(self):
1✔
512
        queryset = Session.objects.all().order_by('-event__datetime_start')
1✔
513
        inactive = self.request.query_params.get('show_inactive', False)
1✔
514
        explicit = self.request.query_params.get('explicit', None)
1✔
515
        private = self.request.query_params.get('private', None)
1✔
516
        event = self.request.query_params.get('event', None)
1✔
517
        user = self.request.query_params.get('user', None)
1✔
518

519
        if not self.request.user or not self.request.user.is_authenticated or \
1✔
520
                not self.request.user.has_perm('spotify.view_session'):
521
            private = False
1✔
522
            inactive = False
1✔
523
            user = None
1✔
524

525
        if not query_parameter_to_boolean(inactive):
1✔
526
            queryset = queryset.filter(
1✔
527
                accepting_requests=True, event__approved=True, event__reviewed=False, event__cancelled=False,
528
                event__closed=False
529
            )
530

531
        if explicit is not None:
1✔
532
            queryset = queryset.filter(allow_explicit=query_parameter_to_boolean(explicit))
×
533
        if private is not None:
1✔
534
            queryset = queryset.filter(private=query_parameter_to_boolean(private))
1✔
535
        if event:
1✔
536
            queryset = queryset.filter(Q(event__event_name__icontains=event) | Q(event__location__name__icontains=event))
1✔
537
        if user:
1✔
538
            queryset = queryset.filter(Q(user__spotify_id__iexact=user) | Q(user__display_name__icontains=user))
1✔
539
        return queryset
1✔
540

541
    @extend_schema(
1✔
542
        operation_id="Get Spotify Sessions",
543
        parameters=[
544
            OpenApiParameter("event", OpenApiTypes.STR, OpenApiParameter.QUERY, False,
545
                             "Filter by event name or location"),
546
            OpenApiParameter("explicit", OpenApiTypes.BOOL, OpenApiParameter.QUERY, False,
547
                             "Filter by whether or not explicit music is allowed"),
548
            OpenApiParameter("private", OpenApiTypes.BOOL, OpenApiParameter.QUERY, False,
549
                             "Filter by whether or not the session is restricted to LNL members only"),
550
            OpenApiParameter("user", OpenApiTypes.STR, OpenApiParameter.QUERY, False,
551
                             "Filter by Spotify accounts with matching username or display name"),
552
            OpenApiParameter("show_inactive", OpenApiTypes.BOOL, OpenApiParameter.QUERY, False,
553
                             "Include inactive sessions", default=False)
554
        ],
555
        responses={
556
            200: OpenApiResponse(SpotifySessionReadOnlySerializer(many=True), description="Ok"),
557
            204: None,
558
            400: OpenApiResponse(description="Bad Request. One or more query parameters were invalid or malformed.")
559
        }
560
    )
561
    def list(self, request):
1✔
562
        """ Use this endpoint to retrieve a list of Spotify song request sessions """
563
        try:
1✔
564
            queryset = self.get_queryset()
1✔
565
        except ValidationError:
×
566
            content = {'detail': 'Bad Request. One or more query parameters were invalid or malformed.'}
×
567
            return Response(content, status=status.HTTP_400_BAD_REQUEST)
×
568

569
        if queryset.count() == 0:
1✔
570
            return Response([], status=status.HTTP_204_NO_CONTENT)
1✔
571

572
        serializer = SpotifySessionReadOnlySerializer(queryset, request=request, many=True)
1✔
573
        return Response(serializer.data)
1✔
574

575
    @extend_schema(
1✔
576
        operation_id="Get Spotify Session Playback State",
577
        responses={
578
            200: OpenApiResponse(SpotifySessionReadOnlySerializer, description="Ok"),
579
            401: OpenApiResponse(description="Not authenticated"),
580
            403: OpenApiResponse(description="Permission denied"),
581
            404: OpenApiResponse(description="Not found")
582
        },
583
        examples=[
584
            OpenApiExample(
585
                'Playback State', examples.spotify_playback_state, response_only=True,
586
                description="Current track and device information comes directly from the Spotify API. Learn more at "
587
                            "<a href='https://developer.spotify.com/documentation/web-api/reference/#/operations/get-"
588
                            "information-about-the-users-current-playback'>https://developer.spotify.com/documentation/"
589
                            "web-api/reference/#/operations/get-information-about-the-users-current-playback</a>."
590
            )
591
        ]
592
    )
593
    def retrieve(self, request, id=None):
1✔
594
        """
595
        Use this endpoint to retrieve the current playback state and configuration for a Spotify song request session.
596
        Authentication is required to access sessions that are not public or are no longer active.
597
        """
598

599
        session = get_object_or_404(Session, slug=id)
1✔
600

601
        if session.private or not session.accepting_requests:
1✔
602
            if not request.user or not request.user.is_authenticated:
1✔
603
                raise NotAuthenticated
1✔
604
            elif not request.user.has_perm('spotify.view_session', session):
1✔
605
                raise PermissionDenied
1✔
606
        serializer = SpotifySessionReadOnlySerializer(session, request=request)
1✔
607
        return Response(serializer.data)
1✔
608

609
    @extend_schema(
1✔
610
        operation_id="Create Spotify Session",
611
        request=SpotifySessionWriteSerializer,
612
        responses={
613
            201: OpenApiResponse(SpotifySessionReadOnlySerializer, description="Ok"),
614
            400: OpenApiResponse(description="Bad request"),
615
            401: OpenApiResponse(description="Unauthorized"),
616
            403: OpenApiResponse(description="Permission denied. Event may be ineligible or current user does not have "
617
                                             "sufficient permissions."),
618
            404: OpenApiResponse(description="Event or Spotify account with the specified id does not exist"),
619
            409: OpenApiResponse(description="Conflict")
620
        }
621
    )
622
    def create(self, request):
1✔
623
        """
624
        Use this endpoint to start a new Spotify song request session. Note that the API does not support paid sessions.
625
        """
626

627
        event_id = request.data.get('event', None)
1✔
628
        account_id = request.data.get('user', None)
1✔
629
        if not event_id or not account_id:
1✔
630
            raise ParseError(detail="Missing one or more required fields. Please supply valid event and account IDs.")
1✔
631

632
        event = get_object_or_404(Event2019, pk=event_id)
1✔
633
        account = get_object_or_404(SpotifyUser, pk=account_id, token_info__isnull=False)
1✔
634

635
        # Check that user has permission to perform this action
636
        if not (request.user in [cc.crew_chief for cc in event.ccinstances.all()] or
1✔
637
                request.user.has_perm('spotify.add_session')) or (account.personal and request.user != account.user):
638
            raise PermissionDenied
1✔
639

640
        # Check that event is eligible
641
        if not event.approved or event.reviewed or event.cancelled or event.closed:
1✔
642
            raise PermissionDenied(detail="Cannot create session at this time: Event ineligible.")
1✔
643

644
        # Check for existing session
645
        if Session.objects.filter(event=event).exists():
1✔
646
            return Response({'detail': 'Session already exists'}, status=status.HTTP_409_CONFLICT)
1✔
647

648
        serializer = SpotifySessionWriteSerializer(data=request.data)
1✔
649
        if serializer.is_valid(raise_exception=True):
1✔
650
            try:
1✔
651
                with transaction.atomic():
1✔
652
                    session = serializer.save()
1✔
653
            except IntegrityError:
1✔
654
                return Response({"detail": "The specified Spotify account is currently in use by another session"},
1✔
655
                                status=status.HTTP_409_CONFLICT)
656

657
            response_serializer = SpotifySessionReadOnlySerializer(session, request=request)
1✔
658
            return Response(response_serializer.data, status=status.HTTP_201_CREATED)
1✔
659
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
×
660

661
    @extend_schema(
1✔
662
        operation_id="Update Spotify Session",
663
        request=inline_serializer(
664
            'SpotifySession',
665
            fields={
666
                'user': serializers.IntegerField(required=False,
667
                                                 help_text="Primary key value of the corresponding Spotify account"),
668
                'accepting_requests': serializers.BooleanField(required=False),
669
                'allow_explicit': serializers.BooleanField(required=False),
670
                'auto_approve': serializers.BooleanField(required=False),
671
                'private': serializers.BooleanField(required=False)
672
            }
673
        ),
674
        responses={
675
            200: OpenApiResponse(SpotifySessionReadOnlySerializer, description="Ok"),
676
            400: OpenApiResponse(description="Bad request"),
677
            401: OpenApiResponse(description="Unauthorized"),
678
            403: OpenApiResponse(description="Permission denied. Updates may no longer be allowed for this session or "
679
                                             "the current user does not have sufficient permissions."),
680
            404: OpenApiResponse(description="Not found"),
681
            409: OpenApiResponse(description="Conflict")
682
        }
683
    )
684
    def partial_update(self, request, id=None):
1✔
685
        """ Use this endpoint to update the configuration for a specific Spotify song request session """
686

687
        session = get_object_or_404(Session, slug=id)
1✔
688

689
        # Check that user has permission to perform this action
690
        if not request.user.has_perm('spotify.change_session', session) or \
1✔
691
                (session.user.personal and request.user != session.user.user) or not session.user.token_info:
692
            raise PermissionDenied
1✔
693

694
        # Check that the session is still eligible to be updated
695
        if not session.event.approved or session.event.reviewed or session.event.cancelled or session.event.closed:
1✔
696
            session.accepting_requests = False
1✔
697
            session.save()
1✔
698
            raise PermissionDenied(detail="Cannot update session at this time: Event ineligible.")
1✔
699

700
        # Verify that user is allowed to use the new requested account (if changed)
701
        account_id = request.data.get('user', None)
1✔
702
        if account_id:
1✔
703
            new_account = get_object_or_404(SpotifyUser, pk=account_id, token_info__isnull=False)
1✔
704
            if new_account.personal and request.user != new_account.user:
1✔
705
                raise PermissionDenied
1✔
706

707
        serializer = SpotifySessionWriteSerializer(session, data=request.data, partial=True)
1✔
708
        if serializer.is_valid(raise_exception=True):
1✔
709
            try:
1✔
710
                with transaction.atomic():
1✔
711
                    session = serializer.save()
1✔
712
            except IntegrityError:
1✔
713
                return Response({"detail": "The Spotify account you selected is currently in use by another session"},
1✔
714
                                status=status.HTTP_409_CONFLICT)
715

716
            response_serializer = SpotifySessionReadOnlySerializer(session, request=request)
1✔
717
            return Response(response_serializer.data, status=status.HTTP_200_OK)
1✔
718
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
×
719

720
    @extend_schema(
1✔
721
        operation_id="Start / Resume Playback",
722
        parameters=[
723
            OpenApiParameter('device', OpenApiTypes.STR, OpenApiParameter.QUERY, False,
724
                             'The unique ID of the target device')
725
        ],
726
        request=None,
727
        responses={
728
            204: OpenApiResponse(description="Playback started"),
729
            401: OpenApiResponse(description="Unauthorized"),
730
            403: OpenApiResponse(description="Permission denied"),
731
            404: OpenApiResponse(description="Not found")
732
        }
733
    )
734
    @action(['PUT'], detail=True)
1✔
735
    def play(self, request, id=None):
1✔
736
        """ Use this endpoint to start / resume playback for a session """
737

738
        session = get_object_or_404(Session, slug=id)
1✔
739

740
        # Check that user has permission to manage playback
741
        if not request.user.has_perm('spotify.manage_playback', session) or \
1✔
742
                (session.user.personal and request.user != session.user.user) or not session.user.token_info:
743
            raise PermissionDenied
1✔
744

745
        # Check to see if event is still active
746
        if not session.event.approved or session.event.reviewed or session.event.cancelled or session.event.closed:
1✔
747
            raise NotFound(detail="Playback controls are currently unavailable for this event")
1✔
748

749
        device_id = self.request.query_params.get('device', None)
1✔
750

751
        error = play(session, device_id)
1✔
752
        if error:
1✔
753
            raise NotFound(detail=error)
1✔
754
        return Response(None, status=status.HTTP_204_NO_CONTENT)
×
755

756
    @extend_schema(
1✔
757
        operation_id="Pause Playback",
758
        request=None,
759
        responses={
760
            204: OpenApiResponse(description="Playback paused"),
761
            401: OpenApiResponse(description="Unauthorized"),
762
            403: OpenApiResponse(description="Permission denied"),
763
            404: OpenApiResponse(description="Not found")
764
        }
765
    )
766
    @action(['PUT'], detail=True)
1✔
767
    def pause(self, request, id=None):
1✔
768
        """ Use this endpoint to pause playback for a session """
769

770
        session = get_object_or_404(Session, slug=id)
1✔
771

772
        # Check that user has permission to manage playback
773
        if not request.user.has_perm('spotify.manage_playback', session) or \
1✔
774
                (session.user.personal and request.user != session.user.user) or not session.user.token_info:
775
            raise PermissionDenied
1✔
776

777
        # Check to see if event is still active
778
        if not session.event.approved or session.event.reviewed or session.event.cancelled or session.event.closed:
1✔
779
            raise NotFound(detail="Playback controls are currently unavailable for this event")
1✔
780

781
        error = pause(session)
1✔
782
        if error:
1✔
783
            raise NotFound(detail=error)
1✔
784
        return Response(None, status=status.HTTP_204_NO_CONTENT)
×
785

786
    @extend_schema(
1✔
787
        operation_id="Skip to Previous",
788
        request=None,
789
        responses={
790
            204: OpenApiResponse(description="Command sent"),
791
            401: OpenApiResponse(description="Unauthorized"),
792
            403: OpenApiResponse(description="Permission denied"),
793
            404: OpenApiResponse(description="Not found")
794
        }
795
    )
796
    @action(['PUT'], detail=True)
1✔
797
    def previous(self, request, id=None):
1✔
798
        """ Use this endpoint to jump to the previous track """
799

800
        session = get_object_or_404(Session, slug=id)
1✔
801

802
        # Check that user has permission to manage playback
803
        if not request.user.has_perm('spotify.manage_playback', session) or \
1✔
804
                (session.user.personal and request.user != session.user.user) or not session.user.token_info:
805
            raise PermissionDenied
1✔
806

807
        # Check to see if event is still active
808
        if not session.event.approved or session.event.reviewed or session.event.cancelled or session.event.closed:
1✔
809
            raise NotFound(detail="Playback controls are currently unavailable for this event")
1✔
810

811
        error = previous(session)
1✔
812
        if error:
1✔
813
            raise NotFound(detail=error)
1✔
814
        return Response(None, status=status.HTTP_204_NO_CONTENT)
×
815

816
    @extend_schema(
1✔
817
        operation_id="Skip to Next",
818
        request=None,
819
        responses={
820
            204: OpenApiResponse(description="Command sent"),
821
            401: OpenApiResponse(description="Unauthorized"),
822
            403: OpenApiResponse(description="Permission denied"),
823
            404: OpenApiResponse(description="Not found")
824
        }
825
    )
826
    @action(['PUT'], detail=True)
1✔
827
    def next(self, request, id=None):
1✔
828
        """ Use this endpoint to skip to the next track """
829

830
        session = get_object_or_404(Session, slug=id)
1✔
831

832
        # Check that user has permission to manage playback
833
        if not request.user.has_perm('spotify.manage_playback', session) or \
1✔
834
                (session.user.personal and request.user != session.user.user) or not session.user.token_info:
835
            raise PermissionDenied
1✔
836

837
        # Check to see if event is still active
838
        if not session.event.approved or session.event.reviewed or session.event.cancelled or session.event.closed:
1✔
839
            raise NotFound(detail="Playback controls are currently unavailable for this event")
1✔
840

841
        error = skip(session)
1✔
842
        if error:
1✔
843
            raise NotFound(detail=error)
1✔
844
        return Response(None, status=status.HTTP_204_NO_CONTENT)
×
845

846

847
class SongRequestViewSet(viewsets.GenericViewSet):
1✔
848
    authentication_classes = [TokenAuthentication]
1✔
849
    permission_classes = [IsAuthenticated]
1✔
850

851
    def get_permissions(self):
1✔
852
        if self.action in ['create']:
1✔
853
            return []
1✔
854
        return super(SongRequestViewSet, self).get_permissions()
1✔
855

856
    def get_queryset(self):
1✔
857
        session_id = self.request.query_params.get('session', None)
1✔
858
        show_approved = self.request.query_params.get('show_approved', False)
1✔
859

860
        if not session_id:
1✔
861
            raise ValidationError("A valid session ID is required")
1✔
862

863
        session = get_object_or_404(Session, slug=session_id)
1✔
864
        queryset = SongRequest.objects.filter(session=session).order_by('pk')
1✔
865

866
        if not show_approved:
1✔
867
            queryset = queryset.exclude(approved=True)
1✔
868

869
        return queryset
1✔
870

871
    @extend_schema(
1✔
872
        operation_id="Get Song Requests",
873
        parameters=[
874
            OpenApiParameter('session', OpenApiTypes.STR, OpenApiParameter.QUERY, True,
875
                             "Unique session ID for the session the requests belong to"),
876
            OpenApiParameter('show_approved', OpenApiTypes.BOOL, OpenApiParameter.QUERY, False,
877
                             "Include requests that have already been approved")
878
        ],
879
        responses={
880
            200: OpenApiResponse(SongRequestSerializer(many=True), description="Ok"),
881
            204: OpenApiResponse(description="Nothing to return"),
882
            401: OpenApiResponse(description="Unauthenticated"),
883
            403: OpenApiResponse(description="Permission denied"),
884
            404: OpenApiResponse(description="Not found. No session matching the provided ID exists.")
885
        },
886
        examples=[
887
            OpenApiExample('Song Request', examples.song_request, response_only=True)
888
        ]
889
    )
890
    def list(self, request):
1✔
891
        """ Use this endpoint to get a list of song requests for an event """
892

893
        try:
1✔
894
            queryset = self.get_queryset()
1✔
895
        except ValidationError as e:
1✔
896
            return Response({'detail': 'Bad request. %s' % e.message}, status=status.HTTP_400_BAD_REQUEST)
1✔
897

898
        if queryset.count() == 0:
1✔
899
            return Response(None, status=status.HTTP_204_NO_CONTENT)
1✔
900

901
        for song_request in queryset.all():
1✔
902
            if not request.user.has_perm('spotify.view_session', song_request.session):
1✔
903
                raise PermissionDenied
1✔
904

905
        serializer = SongRequestSerializer(queryset, many=True)
1✔
906
        return Response(serializer.data, status=status.HTTP_200_OK)
1✔
907

908
    @extend_schema(
1✔
909
        operation_id="Create Song Request",
910
        request=inline_serializer(
911
            'Song Request',
912
            fields={
913
                'session': serializers.CharField(),
914
                'identifier': serializers.CharField(help_text="Unique Spotify identifier for the requested track"),
915
                'first_name': serializers.CharField(help_text="First Name of the person submitting the request"),
916
                'last_name': serializers.CharField(help_text="Last Name of the person submitting the request"),
917
                'email': serializers.EmailField(required=False),
918
                'phone': serializers.CharField(required=False)
919
            }
920
        ),
921
        responses={
922
            201: OpenApiResponse(inline_serializer(
923
                'Song Request Receipt', fields={'estimated_wait': serializers.IntegerField()}), description="Created"
924
            ),
925
            400: OpenApiResponse(description="Bad request"),
926
            401: OpenApiResponse(description="Unauthorized (private sessions)"),
927
            402: OpenApiResponse(description="Payment required"),
928
            403: OpenApiResponse(description="Permission denied (private sessions)"),
929
            404: OpenApiResponse(description="Not found. No session matching the provided ID exists.")
930
        }
931
    )
932
    def create(self, request):
1✔
933
        """ Use this endpoint to submit a new song request (Not supported for paid sessions) """
934

935
        session_id = request.data.get('session', None)
1✔
936

937
        session = get_object_or_404(Session, slug=session_id, accepting_requests=True)
1✔
938

939
        # Check that request is valid
940
        track_id = request.data.get('identifier', None)
1✔
941
        first_name = request.data.get('first_name', None)
1✔
942
        last_name = request.data.get('last_name', None)
1✔
943
        email = request.data.get('email', None)
1✔
944
        phone = request.data.get('phone', None)
1✔
945
        if not track_id or not first_name or not last_name or (not email and not phone):
1✔
946
            raise ParseError(detail="One or more required fields are missing. Note that each request must include an "
×
947
                                    "email address or phone number.")
948

949
        requestor = first_name + " " + last_name
1✔
950

951
        # If a session requires payment, users should use the form on the LNL website
952
        if session.require_payment:
1✔
953
            return Response({'detail': 'Not supported. Payment Required.'}, status=status.HTTP_402_PAYMENT_REQUIRED)
1✔
954

955
        # Private sessions require authentication and are restricted to LNL members only
956
        if session.private:
1✔
957
            if not request.user or not request.user.is_authenticated:
1✔
958
                raise NotAuthenticated(detail="Authentication is required for private sessions")
1✔
959
            elif not request.user.is_lnl:
1✔
960
                raise PermissionDenied
1✔
961

962
        # If an event is no longer eligible to accept requests, refuse to fulfil the request
963
        if not session.event.approved or session.event.cancelled or session.event.closed or session.event.reviewed:
1✔
964
            session.accepting_requests = False
1✔
965
            session.save()
1✔
966
            raise PermissionDenied(detail="Failed to submit request: Session is no longer accepting requests.")
1✔
967

968
        # Create the request and respond with the estimated wait time
UNCOV
969
        track = get_track(session, track_id)
×
UNCOV
970
        if not track:
×
UNCOV
971
            return Response({'detail': 'Failed to retrieve track information'}, status=status.HTTP_404_NOT_FOUND)
×
972

973
        song_request = {
×
974
            'session': request.data['session'],
975
            'name': track['name'],
976
            'identifier': track_id,
977
            'duration': track['duration_ms'],
978
            'submitted_by': requestor,
979
            'email': email,
980
            'phone': phone
981
        }
982
        serializer = SongRequestSerializer(data=song_request)
×
983
        if serializer.is_valid(raise_exception=True):
×
984
            serializer.save()
×
985
        return Response({'estimated_wait': queue_estimate(session, True)}, status=status.HTTP_201_CREATED)
×
986

987
    @extend_schema(
1✔
988
        operation_id="Approve Song Request",
989
        responses={
990
            200: OpenApiResponse(SongRequestSerializer, description="Ok"),
991
            401: OpenApiResponse(description="Unauthorized"),
992
            403: OpenApiResponse(description="Permission denied"),
993
            404: OpenApiResponse(description="Not found")
994
        },
995
        examples=[
996
            OpenApiExample('Song Request', examples.song_request, response_only=True)
997
        ]
998
    )
999
    def partial_update(self, request, pk):
1✔
1000
        """ Use this endpoint to approve a song request """
1001

1002
        song_request = get_object_or_404(SongRequest, pk=pk, approved=False)
1✔
1003

1004
        if not request.user.has_perm('spotify.approve_song_request', song_request):
1✔
1005
            raise PermissionDenied
1✔
1006

1007
        # If another session using the same Spotify account has started, refuse any further modifications
1008
        active_sessions = Session.objects.filter(user=song_request.session.user, accepting_requests=True).all()
1✔
1009
        if song_request.session not in active_sessions and active_sessions.count() > 0:
1✔
1010
            raise PermissionDenied(detail="This request belongs to a session that can no longer be modified. The "
1✔
1011
                                          "corresponding Spotify account is currently in use elsewhere.")
1012

1013
        # If the event is no longer active, deny permission
1014
        event = song_request.session.event
1✔
1015
        if not event.approved or event.reviewed or event.closed or event.cancelled:
1✔
1016
            raise PermissionDenied(detail="This feature is currently unavailable: Event ineligible")
1✔
1017

1018
        serializer = SongRequestSerializer(song_request, data={'approved': True}, partial=True)
1✔
1019
        if serializer.is_valid(raise_exception=True):
1✔
1020
            serializer.save()
1✔
1021
        return Response(serializer.data, status=status.HTTP_200_OK)
1✔
1022

1023

1024
def query_parameter_to_boolean(value):
1✔
1025
    """ Converts query parameter value to boolean (if not already) """
1026

1027
    if isinstance(value, bool) or value is None:
1✔
1028
        return value
1✔
1029

1030
    if isinstance(value, int):
1✔
1031
        value = str(value)
×
1032

1033
    if value.lower() in ["false", "0"]:
1✔
1034
        return False
×
1035
    elif value.lower() in ["true", "1"]:
1✔
1036
        return True
1✔
1037
    return None
×
1038

1039

1040
@login_required
1✔
1041
def request_token(request, client_id):
1✔
1042
    """
1043
    Applications should direct users to this page to grant access to their application and to request an API token
1044

1045
    :param client_id: The corresponding application's Client ID
1046
    """
1047

1048
    missing = True
1✔
1049
    sent = False
1✔
1050
    context = {'NO_FOOT': True, 'NO_NAV': True, 'NO_API': True,
1✔
1051
               'styles': "#main {\n\tbackground-color: white !important;\n\theight: 100vh;\n}\n.text-white {"
1052
                         "\n\tcolor: black !important;\n}\n.help-block {\n\tdisplay: none;\n}"}
1053

1054
    # Verify Client ID and retrieve corresponding application record
1055
    if not settings.CRYPTO_KEY:
1✔
1056
        raise Exception("Unable to process Client ID. Cryptographic key is missing.")
×
1057

1058
    cipher_suite = Fernet(settings.CRYPTO_KEY)
1✔
1059
    api_key = cipher_suite.decrypt(client_id.encode('utf-8')).decode('utf-8')
1✔
1060
    app = get_object_or_404(Extension, api_key=api_key)
1✔
1061

1062
    # If the user just granted permissions to the application, update the app record
1063
    if reverse("accounts:scope-request") in request.META.get('HTTP_REFERER', ''):
1✔
1064
        app.users.add(request.user)
1✔
1065

1066
    # If the user hasn't yet granted access to the application, redirect them there first
1067
    if request.user not in app.users.all():
1✔
1068
        request.session["app"] = app.name
1✔
1069
        request.session["resource"] = "your LNL account"
1✔
1070
        try:
1✔
1071
            request.session["icon"] = app.icon.path
1✔
1072
        except ValueError:
1✔
1073
            request.session["icon"] = "https://lnl.wpi.edu/assets/ico/apple-touch-icon-114-precomposed.png"
1✔
1074
        request.session["scopes"] = ["Check into events, on your behalf", "Check out of events, on your behalf"]
1✔
1075
        request.session["callback_uri"] = reverse("api:request-token", args=[client_id])
1✔
1076
        prefs, created = UserPreferences.objects.get_or_create(user=request.user)
1✔
1077
        request.session["inverted"] = prefs.theme == 'dark'
1✔
1078
        return HttpResponseRedirect(reverse("accounts:scope-request"))
1✔
1079

1080
    if request.user.phone and request.user.carrier:
1✔
1081
        missing = False
1✔
1082
        context['phone'] = "(" + request.user.phone[:3] + ") " + request.user.phone[3:6] + "-" + request.user.phone[6:]
1✔
1083

1084
    if request.method == 'POST':
1✔
1085
        form = SMSOptInForm(request.POST, instance=request.user, request=request, exists=not missing)
1✔
1086
        if form.is_valid():
1✔
1087
            if missing:
1✔
1088
                form.save()
1✔
1089
                missing = False
1✔
1090
                form = SMSOptInForm(request=request, exists=not missing)
1✔
1091
                context['phone'] = "(" + request.user.phone[:3] + ") " + request.user.phone[3:6] + "-" + \
1✔
1092
                                   request.user.phone[6:]
1093
            else:
1094
                code = randint(100000, 999999)
1✔
1095
                message = {
1✔
1096
                    "user": request.user,
1097
                    "message": str(code) + "\nUse this code for LNL verification"
1098
                }
1099
                email = generate_sms_email(message)
1✔
1100
                try:
1✔
1101
                    token_request = TokenRequest.objects.get(user=request.user)
1✔
1102
                    token_request.code = code
1✔
1103
                    token_request.attempts = settings.TFV_ATTEMPTS
1✔
1104
                    token_request.timestamp = timezone.now()
1✔
1105
                    token_request.save()
1✔
1106
                except TokenRequest.DoesNotExist:
1✔
1107
                    TokenRequest.objects.create(user=request.user, code=code, attempts=settings.TFV_ATTEMPTS,
1✔
1108
                                                timestamp=timezone.now())
1109
                email.send()
1✔
1110
                sent = True
1✔
1111
    else:
1112
        form = SMSOptInForm(instance=request.user, request=request, exists=not missing)
1✔
1113
    context['form'] = form
1✔
1114
    context['missing'] = missing
1✔
1115
    context['sent'] = sent
1✔
1116
    return render(request, "phone_verification.html", context)
1✔
1117

1118

1119
@extend_schema(
1✔
1120
    operation_id="Token",
1121
    request=TokenRequestSerializer,
1122
    responses={
1123
        200: inline_serializer('Token', fields={'token': serializers.CharField()}),
1124
        400: OpenApiResponse(description="Bad request. One or more required keys are missing or malformed."),
1125
        403: OpenApiResponse(description="User has not granted proper permissions for the application or the provided "
1126
                                         "verification code is invalid."),
1127
        404: OpenApiResponse(description="The specified user, application, or corresponding token request could not "
1128
                                         "be found."),
1129
        410: OpenApiResponse(description="Verification code has expired or the number of allowed attempts has been "
1130
                                         "exceeded.")
1131
    }
1132
)
1133
@api_view(['POST'])
1✔
1134
@authentication_classes([])
1✔
1135
def fetch_token(request):
1✔
1136
    """
1137
    Use this endpoint to retrieve a user's API token. The user will need to have already granted access to your
1138
    application and should have already received a verification code.
1139
    """
1140
    data = request.data
1✔
1141
    key = data.get('APIKey', None)
1✔
1142
    code = data.get('code', None)
1✔
1143
    username = data.get('username', None)
1✔
1144
    if key is None or code is None or username is None:
1✔
1145
        raise ParseError(detail="Bad request. One or more required keys are missing or malformed.", code=400)
1✔
1146
    app = get_object_or_404(Extension, api_key=key, enabled=True)
1✔
1147
    user = get_object_or_404(get_user_model(), username=username)
1✔
1148
    if app not in user.connected_services.filter(enabled=True):
1✔
1149
        raise AuthenticationFailed(detail="User has not granted proper permissions for this application", code=403)
×
1150
    try:
1✔
1151
        token_request = TokenRequest.objects.get(user=user)
1✔
1152
    except TokenRequest.DoesNotExist:
×
1153
        raise NotFound(detail="Not found", code=404)
×
1154
    if token_request.timestamp + timezone.timedelta(seconds=settings.TFV_CODE_EXPIRES) <= timezone.now() or \
1✔
1155
            token_request.attempts == 0:
1156
        return Response({'detail': "Code has expired or exceeded allowed attempts"}, status=status.HTTP_410_GONE)
1✔
1157
    if token_request.code != int(code):
1✔
1158
        token_request.attempts -= 1
1✔
1159
        token_request.save()
1✔
1160
        return Response(
1✔
1161
            {"detail": "Invalid verification code. {} attempts remaining.".format(token_request.attempts)},
1162
            status=status.HTTP_403_FORBIDDEN
1163
        )
1164

1165
    # Everything checks out
1166
    token_request.code = 0
1✔
1167
    token_request.attempts = 0
1✔
1168
    token_request.save()
1✔
1169

1170
    token, created = Token.objects.get_or_create(user=user)
1✔
1171

1172
    token = {"token": token.key}
1✔
1173

1174
    return Response(token, status=status.HTTP_200_OK)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc