From 6223ab8abe210d341eea3c5e58d90caf0da1f3b9 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Tue, 30 Jan 2024 02:19:27 -0800 Subject: [PATCH 1/8] [Work-in-Progress] Scheduling of Live Events(#77) - Early versions of API endpoints/URLs to add and delete LiveEvents. - Gets LiveEvent objects using their scheduled_at time, for adding/deleting as well as marking attendance. - Updates Mixin to add a rank property to help correctly add attributes to LiveEvent objects. - Issues: - - On the URL: `http://127.0.0.1:8000/sequences/steve/seq-cert-live-event/1/` if a user is not logged in, they are able to make POST requests and the timer updates, but if logged in as either steve or a superuser it returns `djaodjin-pages-vue.js:581 Error sending ping: Forbidden` Haven't investigated yet. --- pages/api/progress.py | 37 +++++++++++++------- pages/api/sequences.py | 58 +++++++++++++++++++++++++++---- pages/mixins.py | 10 ++++-- pages/models.py | 9 +++-- pages/serializers.py | 22 +++++++++++- pages/urls/api/editables.py | 9 ++++- pages/views/sequences.py | 26 ++++++++++---- testsite/fixtures/default-db.json | 2 +- 8 files changed, 139 insertions(+), 34 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index 256f476..16e7633 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -22,8 +22,10 @@ # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import dateutil.parser from datetime import timedelta + from deployutils.helpers import datetime_or_now from rest_framework import response as api_response, status from rest_framework.generics import DestroyAPIView, ListAPIView, RetrieveAPIView @@ -253,21 +255,32 @@ def post(self, request, *args, **kwargs): "detail": "Attendance marked successfully" } """ - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) + # serializer = self.get_serializer(data=request.data) + # serializer.is_valid(raise_exception=True) + # scheduled_at_str = serializer.data['scheduled_at'] + scheduled_at = request.data.get('scheduled_at', None) + scheduled_at_str = None + try: + scheduled_at_str = dateutil.parser.parse(scheduled_at) + # Placeholder try/except clauses + except: + pass progress = self.get_object() element = progress.step - live_event = LiveEvent.objects.filter(element=element.content).first() - - # We use if live_event to confirm the existence of the LiveEvent object - if (live_event and - progress.viewing_duration <= element.min_viewing_duration): - progress.viewing_duration = element.min_viewing_duration - progress.save() - return api_response.Response( - {'detail': 'Attendance marked successfully'}, - status=status.HTTP_200_OK) + # Finding the LiveEvent from its scheduled_at + if scheduled_at_str: + live_event = LiveEvent.objects.filter( + element=element.content, scheduled_at=scheduled_at_str).first() + + # We use if live_event to confirm the existence of the LiveEvent object + if (live_event and + progress.viewing_duration <= element.min_viewing_duration): + progress.viewing_duration = element.min_viewing_duration + progress.save() + return api_response.Response( + {'detail': 'Attendance marked successfully'}, + status=status.HTTP_200_OK) return api_response.Response( {'detail': 'Attendance not marked'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/pages/api/sequences.py b/pages/api/sequences.py index 789b07e..7d1c3c1 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -21,6 +21,7 @@ # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import dateutil.parser import logging from django.db import transaction, IntegrityError @@ -28,12 +29,14 @@ from rest_framework import response as api_response, status from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.generics import (get_object_or_404, DestroyAPIView, - ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView) + ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView, CreateAPIView) +from rest_framework.views import APIView -from ..mixins import AccountMixin, SequenceMixin -from ..models import Sequence, EnumeratedElements +from ..mixins import AccountMixin, SequenceMixin, PageElementMixin +from ..models import Sequence, EnumeratedElements, PageElement, LiveEvent from ..serializers import (SequenceSerializer, SequenceCreateSerializer, - EnumeratedElementSerializer) + EnumeratedElementSerializer, LiveEventSerializer) + LOGGER = logging.getLogger(__name__) @@ -401,9 +404,9 @@ def post(self, request, *args, **kwargs): {'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) - return api_response.Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST) + # return api_response.Response( + # serializer.errors, + # status=status.HTTP_400_BAD_REQUEST) class RemoveElementFromSequenceAPIView(AccountMixin, SequenceMixin, @@ -432,3 +435,44 @@ def perform_destroy(self, instance): self.sequence.has_certificate = False self.sequence.save() instance.delete() + + +class LiveEventCreateAPIView(CreateAPIView): + # Adds LiveEvent to PageElement + serializer_class = LiveEventSerializer + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class LiveEventDeleteAPIView(PageElementMixin, APIView): + # Deletes a LiveEvent + serializer_class = LiveEventSerializer + + def get_object(self, request): + # Under the assumption that a Page Element only has + # a single LiveEvent object + + # Which is not true + + # Even if we have the same scheduled_at, there's still no + # guarantee it's a singular one, there could be multiple + # LiveEvents with the same scheduled_at datetime. + + # Maybe a slug field? or set unique_together for + # each pageelement/scheduled_at? + scheduled_at = request.data.get('scheduled_at', None) + try: + # Convert string to datetime object + scheduled_at_str = dateutil.parser.parse(scheduled_at) + except (ValueError, TypeError): + return + + obj = LiveEvent.objects.get(element=self.element, scheduled_at=scheduled_at_str) + return obj + + def delete(self, request, *args, **kwargs): + obj = self.get_object(request) + obj.delete() + + return api_response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/pages/mixins.py b/pages/mixins.py index e961cb7..0b336f1 100644 --- a/pages/mixins.py +++ b/pages/mixins.py @@ -144,7 +144,7 @@ def element(self): class SequenceMixin(object): """ - Returns an ``User`` from a URL. + Returns a ``Sequence`` from a URL. """ sequence_url_kwarg = 'sequence' @@ -314,12 +314,18 @@ class EnumeratedProgressMixin(SequenceProgressMixin): rank_url_kwarg = 'rank' + @property + def rank(self): + if not hasattr(self, '_rank'): + self._rank = self.kwargs.get(self.rank_url_kwarg, 1) + return self._rank + @property def progress(self): if not hasattr(self, '_progress'): step = get_object_or_404(EnumeratedElements.objects.all(), sequence=self.sequence, - rank=self.kwargs.get(self.rank_url_kwarg, 1)) + rank=self.rank) with transaction.atomic(): self._progress, _ = EnumeratedProgress.objects.get_or_create( sequence_progress=self.sequence_progress, diff --git a/pages/models.py b/pages/models.py index e27147c..c282c7a 100644 --- a/pages/models.py +++ b/pages/models.py @@ -396,6 +396,9 @@ class LiveEvent(models.Model): def __str__(self): return "%s-live" % str(self.element) + + class Meta: + unique_together = ('element', 'scheduled_at') @python_2_unicode_compatible @@ -483,12 +486,12 @@ def is_completed(self): enumerated_elements = EnumeratedElements.objects.filter( sequence=self.sequence).order_by('rank') user_enumerated_progress = EnumeratedProgress.objects.filter( - progress=self, - rank__in=enumerated_elements.values_list( + sequence_progress=self, + step__rank__in=enumerated_elements.values_list( 'rank', flat=True)) for element in enumerated_elements: if not user_enumerated_progress.filter( - rank=element.rank, + step__rank=element.rank, viewing_duration__gte=element.min_viewing_duration ).exists(): return False diff --git a/pages/serializers.py b/pages/serializers.py index 0836edb..c7884a8 100644 --- a/pages/serializers.py +++ b/pages/serializers.py @@ -32,7 +32,7 @@ from . import settings from .compat import gettext_lazy as _, is_authenticated from .models import (Comment, Follow, PageElement, Vote, Sequence, - EnumeratedElements) + EnumeratedElements, LiveEvent) #pylint: disable=abstract-method @@ -383,3 +383,23 @@ class AttendanceInputSerializer(serializers.Serializer): Serializer to validate input to mark users' attendance to a LiveEvent(LiveEventAttendanceAPIView) """ + scheduled_at = serializers.DateTimeField( + help_text='Date/time the live event is scheduled') + + +class LiveEventSerializer(serializers.ModelSerializer): + element = serializers.SlugRelatedField( + queryset=PageElement.objects.all(), + slug_field="slug", + help_text=_("LiveEvent the enumerated element is for"), + required=True) + scheduled_at = serializers.DateTimeField( + help_text='Date/time the live event is scheduled') + location = serializers.URLField( + help_text='URL to the live event') + max_attendees = serializers.IntegerField(default=0, + help_text='Max attendees for the LiveEvent') + + class Meta: + model = LiveEvent + fields = ('element', 'scheduled_at', 'location', 'max_attendees') \ No newline at end of file diff --git a/pages/urls/api/editables.py b/pages/urls/api/editables.py index a2c7be2..cb89e32 100644 --- a/pages/urls/api/editables.py +++ b/pages/urls/api/editables.py @@ -33,10 +33,17 @@ PageElementMirrorAPIView, PageElementMoveAPIView) from ...api.sequences import (SequenceListCreateAPIView, SequenceRetrieveUpdateDestroyAPIView, - RemoveElementFromSequenceAPIView, AddElementToSequenceAPIView) + RemoveElementFromSequenceAPIView, AddElementToSequenceAPIView, + LiveEventCreateAPIView, LiveEventDeleteAPIView) urlpatterns = [ + path('elements/live-event/', + LiveEventDeleteAPIView.as_view(), + name='liveeventdel'), + path('elements/live-event', + LiveEventCreateAPIView.as_view(), + name='liveeventadd'), path(r'sequences//elements/', RemoveElementFromSequenceAPIView.as_view(), name='api_remove_element_from_sequence'), diff --git a/pages/views/sequences.py b/pages/views/sequences.py index 082188f..4c7406b 100644 --- a/pages/views/sequences.py +++ b/pages/views/sequences.py @@ -71,16 +71,20 @@ def get_context_data(self, **kwargs): class SequencePageElementView(EnumeratedProgressMixin, TemplateView): template_name = 'pages/app/sequences/pageelement.html' - + # is_live_event and is_certificate flags not being added def get_context_data(self, **kwargs): #pylint:disable=too-many-locals context = super(SequencePageElementView, self).get_context_data(**kwargs) - element = self.progress.step + queryset = self.get_queryset() + element = list(self.decorate_queryset(queryset))[0] + # It keeps losing the decorated attributes so we turn it into a list + previous_element = EnumeratedElements.objects.filter( sequence=element.sequence, rank__lt=element.rank).order_by( '-rank').first() + next_element = EnumeratedElements.objects.filter( sequence=element.sequence, rank__gt=element.rank).order_by( 'rank').first() @@ -90,9 +94,17 @@ def get_context_data(self, **kwargs): 'sequence_page_element_view', args=(self.user, element.sequence, previous_element.rank)) if next_element: - next_element.url = reverse( - 'sequence_page_element_view', - args=(self.user, element.sequence, next_element.rank)) + # Linking to the certificate download page if next_element + # is a certificate + if self.sequence.has_certificate and next_element.rank \ + == self.last_rank_element.rank: + next_element.url = reverse( + 'certificate_download', + args=(self.user, element.sequence)) + else: + next_element.url = reverse( + 'sequence_page_element_view', + args=(self.user, element.sequence, next_element.rank)) viewing_duration_seconds = ( self.progress.viewing_duration.total_seconds() if self.progress.viewing_duration else 0) @@ -132,7 +144,7 @@ def get_context_data(self, **kwargs): return context -class CertificateDownloadView(SequenceProgressMixin, DetailView): +class CertificateDownloadView(SequenceProgressMixin, TemplateView): template_name = 'pages/certificate.html' response_class = PdfTemplateResponse @@ -160,7 +172,7 @@ def get_context_data(self, **kwargs): return context def get(self, request, *args, **kwargs): - if (self.sequence_progress.has_certificate and + if (self.sequence_progress.sequence.has_certificate and not self.sequence_progress.is_completed): raise PermissionDenied("Certificate is not available for download"\ " until you complete all elements.") diff --git a/testsite/fixtures/default-db.json b/testsite/fixtures/default-db.json index 9f71e3d..047d10f 100644 --- a/testsite/fixtures/default-db.json +++ b/testsite/fixtures/default-db.json @@ -231,7 +231,7 @@ }, { "fields": { - "element": 100, + "element": 107, "created_at": "2023-03-15T10:00:00Z", "scheduled_at": "2023-04-10T15:00:00Z", "location": "http://127.0.0.1:8000/webinar/sustainability", From df022703dfee7b8a548c0724c9ffb14c4fe69b29 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Fri, 2 Feb 2024 01:46:58 -0800 Subject: [PATCH 2/8] Allows adding/removing LiveEvents and other functionalities - Added LiveEvent creation/deletion API Endpoints - Added ability to view multiple LiveEvents belonging to the same PageElement on the front-end view. Could implement this with an API endpoint instead and call that for views individual users. Needs logic updating to be functional, ie. dealing with PageElements without liveevents and other edge cases - Context-specific comments in code with comments and questions. To be removed before final version. --- pages/api/progress.py | 35 +++++------- pages/api/sequences.py | 56 +++++++++---------- pages/mixins.py | 9 ++- pages/models.py | 7 ++- pages/serializers.py | 21 +++---- .../pages/app/sequences/pageelement.html | 15 ++++- pages/urls/api/editables.py | 15 +++-- pages/urls/api/sequences.py | 2 +- pages/views/sequences.py | 8 +-- testsite/fixtures/default-db.json | 13 +++++ 10 files changed, 99 insertions(+), 82 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index 16e7633..477083d 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -22,10 +22,8 @@ # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import dateutil.parser from datetime import timedelta - from deployutils.helpers import datetime_or_now from rest_framework import response as api_response, status from rest_framework.generics import DestroyAPIView, ListAPIView, RetrieveAPIView @@ -34,8 +32,7 @@ from ..docs import extend_schema from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin from ..models import EnumeratedElements, EnumeratedProgress, LiveEvent -from ..serializers import (EnumeratedProgressSerializer, - AttendanceInputSerializer) +from ..serializers import (EnumeratedProgressSerializer, LiveEventSerializer) class EnumeratedProgressListAPIView(SequenceProgressMixin, ListAPIView): @@ -228,8 +225,6 @@ class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView): rank_url_kwarg = 'rank' def get_serializer_class(self): - if self.request.method.lower() == 'post': - return AttendanceInputSerializer return super(LiveEventAttendanceAPIView, self).get_serializer_class() def post(self, request, *args, **kwargs): @@ -255,32 +250,30 @@ def post(self, request, *args, **kwargs): "detail": "Attendance marked successfully" } """ - # serializer = self.get_serializer(data=request.data) - # serializer.is_valid(raise_exception=True) - # scheduled_at_str = serializer.data['scheduled_at'] - scheduled_at = request.data.get('scheduled_at', None) - scheduled_at_str = None - try: - scheduled_at_str = dateutil.parser.parse(scheduled_at) - # Placeholder try/except clauses - except: - pass - + # Get the LiveEvent using request.data + index = request.data.get('index', None) progress = self.get_object() element = progress.step - # Finding the LiveEvent from its scheduled_at - if scheduled_at_str: + if index: live_event = LiveEvent.objects.filter( - element=element.content, scheduled_at=scheduled_at_str).first() + element=element.content, index=index).first() # We use if live_event to confirm the existence of the LiveEvent object if (live_event and - progress.viewing_duration <= element.min_viewing_duration): + progress.viewing_duration < element.min_viewing_duration): progress.viewing_duration = element.min_viewing_duration progress.save() return api_response.Response( {'detail': 'Attendance marked successfully'}, status=status.HTTP_200_OK) + # To prevent marking attendance continuously on a LiveEvent + # that has already been marked + elif (live_event and + progress.viewing_duration == element.min_viewing_duration) and ( + element.min_viewing_duration > 0): + return api_response.Response( + {'detail': 'Attendance already marked'}, + status=status.HTTP_200_OK) return api_response.Response( {'detail': 'Attendance not marked'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/pages/api/sequences.py b/pages/api/sequences.py index 7d1c3c1..ca39cb0 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -32,7 +32,7 @@ ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView, CreateAPIView) from rest_framework.views import APIView -from ..mixins import AccountMixin, SequenceMixin, PageElementMixin +from ..mixins import AccountMixin, SequenceMixin, PageElementMixin, EnumeratedProgressMixin from ..models import Sequence, EnumeratedElements, PageElement, LiveEvent from ..serializers import (SequenceSerializer, SequenceCreateSerializer, EnumeratedElementSerializer, LiveEventSerializer) @@ -437,42 +437,38 @@ def perform_destroy(self, instance): instance.delete() -class LiveEventCreateAPIView(CreateAPIView): - # Adds LiveEvent to PageElement +class LiveEventListCreatePIView(AccountMixin, PageElementMixin, ListCreateAPIView): + # Should this be in editables(?) serializer_class = LiveEventSerializer + def get_queryset(self): + queryset = LiveEvent.objects.all() + if self.element_url_kwarg in self.kwargs: + queryset = queryset.filter(element=self.element) + if self.account_url_kwarg in self.kwargs: + # Won't be necessary in the front-end view so the + # if statement helps + queryset = queryset.filter(element__account=self.account) + return queryset + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + def post(self, request, *args, **kwargs): + # We're asking for an Element here again(via the Serializer), + # even though we've ascribed an element in the URL return self.create(request, *args, **kwargs) -class LiveEventDeleteAPIView(PageElementMixin, APIView): +class LiveEventDeleteAPIView(PageElementMixin, DestroyAPIView): # Deletes a LiveEvent - serializer_class = LiveEventSerializer - - def get_object(self, request): - # Under the assumption that a Page Element only has - # a single LiveEvent object - # Which is not true - - # Even if we have the same scheduled_at, there's still no - # guarantee it's a singular one, there could be multiple - # LiveEvents with the same scheduled_at datetime. - - # Maybe a slug field? or set unique_together for - # each pageelement/scheduled_at? - scheduled_at = request.data.get('scheduled_at', None) - try: - # Convert string to datetime object - scheduled_at_str = dateutil.parser.parse(scheduled_at) - except (ValueError, TypeError): - return - - obj = LiveEvent.objects.get(element=self.element, scheduled_at=scheduled_at_str) - return obj + def get_object(self): + return get_object_or_404(LiveEvent.objects.all(), + element=self.element, index=self.kwargs.get('index')) def delete(self, request, *args, **kwargs): - obj = self.get_object(request) - obj.delete() - - return api_response.Response(status=status.HTTP_204_NO_CONTENT) + # How do we deal with the fact that this removes + # the live event but not the enumeratedelement which + # might not have any attached liveevents? + return self.destroy(self, request, *args, **kwargs) diff --git a/pages/mixins.py b/pages/mixins.py index 0b336f1..b621803 100644 --- a/pages/mixins.py +++ b/pages/mixins.py @@ -29,6 +29,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.db import transaction +from django.db.models import F from django.http import Http404 from rest_framework.generics import get_object_or_404 @@ -283,7 +284,7 @@ def update_element(self, obj): obj.title = obj.content.title obj.url = reverse('sequence_page_element_view', args=(self.user, self.sequence, obj.rank)) - obj.is_live_event = obj.content.slug in self.live_events + obj.is_live_event = obj.content.slug in self.live_events.values_list('element_slug', flat=True) obj.is_certificate = (obj.rank == self.last_rank_element.rank) if \ self.last_rank_element else False @@ -296,8 +297,10 @@ def get_queryset(self): def decorate_queryset(self, queryset): self.live_events = LiveEvent.objects.filter( - element__in=[obj.content for obj in queryset] - ).values_list('element__slug', flat=True) + element__in=[ + obj.content for obj in queryset]).order_by( + 'scheduled_at').annotate( + element_slug=F('element__slug')) self.last_rank_element = None if self.sequence.has_certificate: diff --git a/pages/models.py b/pages/models.py index c282c7a..3219ca0 100644 --- a/pages/models.py +++ b/pages/models.py @@ -389,16 +389,19 @@ class LiveEvent(models.Model): help_text=_("Date/time the live event was created (in ISO format)")) scheduled_at = models.DateTimeField( help_text=_("Date/time the live event is scheduled (in ISO format)")) + index = models.PositiveSmallIntegerField(default=1, + help_text="Unique integer to denote the index of the LiveEvent") location = models.URLField(_("URL to the calendar event"), max_length=2083) max_attendees = models.IntegerField(default=0) extra = get_extra_field_class()(null=True, blank=True, help_text=_("Extra meta data (can be stringify JSON)")) - + # Maybe we can add "status" to the extra and use it to filter + # live events for ones that are active or a new status field def __str__(self): return "%s-live" % str(self.element) class Meta: - unique_together = ('element', 'scheduled_at') + unique_together = ('element', 'index') @python_2_unicode_compatible diff --git a/pages/serializers.py b/pages/serializers.py index c7884a8..aac47f8 100644 --- a/pages/serializers.py +++ b/pages/serializers.py @@ -378,28 +378,21 @@ class Meta(EnumeratedElementSerializer.Meta): 'certificate', 'viewing_duration',) -class AttendanceInputSerializer(serializers.Serializer): - """ - Serializer to validate input to mark users' attendance - to a LiveEvent(LiveEventAttendanceAPIView) - """ - scheduled_at = serializers.DateTimeField( - help_text='Date/time the live event is scheduled') - - class LiveEventSerializer(serializers.ModelSerializer): element = serializers.SlugRelatedField( queryset=PageElement.objects.all(), slug_field="slug", - help_text=_("LiveEvent the enumerated element is for"), + help_text=_('LiveEvent the enumerated element is for'), required=True) scheduled_at = serializers.DateTimeField( - help_text='Date/time the live event is scheduled') + help_text=_('Date/time the live event is scheduled')) + index = serializers.IntegerField( + help_text=_('Unique integer to denote the index of the LiveEvent')) location = serializers.URLField( - help_text='URL to the live event') + help_text=_('URL to the live event')) max_attendees = serializers.IntegerField(default=0, - help_text='Max attendees for the LiveEvent') + help_text=_('Max attendees for the LiveEvent')) class Meta: model = LiveEvent - fields = ('element', 'scheduled_at', 'location', 'max_attendees') \ No newline at end of file + fields = ('element', 'scheduled_at', 'index', 'location', 'max_attendees') diff --git a/pages/templates/pages/app/sequences/pageelement.html b/pages/templates/pages/app/sequences/pageelement.html index 245bfb5..95e8fef 100644 --- a/pages/templates/pages/app/sequences/pageelement.html +++ b/pages/templates/pages/app/sequences/pageelement.html @@ -15,7 +15,20 @@

{{ element.content.title }}

{% if element.is_live_event %} -

Live Event URL: {{ element.content.events.first.location }}

+ + + + + + + {% for event in events %} + + + + + + {% endfor %} +
Event URLEvent DateEvent Max Attendees
{{ event.location }}{{ event.scheduled_at }}{{ event.max_attendees }}
{% elif element.is_certificate %}

This is a certificate. Download Certificate

diff --git a/pages/urls/api/editables.py b/pages/urls/api/editables.py index cb89e32..97dc3ab 100644 --- a/pages/urls/api/editables.py +++ b/pages/urls/api/editables.py @@ -34,16 +34,19 @@ from ...api.sequences import (SequenceListCreateAPIView, SequenceRetrieveUpdateDestroyAPIView, RemoveElementFromSequenceAPIView, AddElementToSequenceAPIView, - LiveEventCreateAPIView, LiveEventDeleteAPIView) + LiveEventListCreatePIView, LiveEventDeleteAPIView) urlpatterns = [ - path('elements/live-event/', + # Move these live event URLs with PageElement URLs + # They also have the account in the URL, not necessary? + path('elements//live-events/', LiveEventDeleteAPIView.as_view(), - name='liveeventdel'), - path('elements/live-event', - LiveEventCreateAPIView.as_view(), - name='liveeventadd'), + name='api_live_event_destroy'), + path('elements//live-events', + LiveEventListCreatePIView.as_view(), + name='api_live_event_list_create'), + path(r'sequences//elements/', RemoveElementFromSequenceAPIView.as_view(), name='api_remove_element_from_sequence'), diff --git a/pages/urls/api/sequences.py b/pages/urls/api/sequences.py index 1afb550..4c5b7c0 100644 --- a/pages/urls/api/sequences.py +++ b/pages/urls/api/sequences.py @@ -32,7 +32,7 @@ urlpatterns = [ - path('//', + path('///', LiveEventAttendanceAPIView.as_view(), name='api_mark_attendance'), path('/', diff --git a/pages/views/sequences.py b/pages/views/sequences.py index 4c7406b..206f60d 100644 --- a/pages/views/sequences.py +++ b/pages/views/sequences.py @@ -43,7 +43,7 @@ class SequenceProgressView(SequenceProgressMixin, TemplateView): def get_context_data(self, **kwargs): context = super(SequenceProgressView, self).get_context_data(**kwargs) - queryset = self.get_queryset() + queryset = self.get_queryset().order_by('rank') decorated_queryset = self.decorate_queryset(queryset) context.update({ @@ -129,9 +129,9 @@ def get_context_data(self, **kwargs): } if hasattr(element, 'is_live_event') and element.is_live_event: - event = element.content.events.first() - if event: - context_urls['live_event_location'] = event.location + events = self.live_events + if events: + context.update({'events': events}) if hasattr(element, 'is_certificate') and element.is_certificate: certificate = element.sequence.get_certificate diff --git a/testsite/fixtures/default-db.json b/testsite/fixtures/default-db.json index 047d10f..dd33479 100644 --- a/testsite/fixtures/default-db.json +++ b/testsite/fixtures/default-db.json @@ -234,12 +234,25 @@ "element": 107, "created_at": "2023-03-15T10:00:00Z", "scheduled_at": "2023-04-10T15:00:00Z", + "index": 1, "location": "http://127.0.0.1:8000/webinar/sustainability", "max_attendees": 100, "extra": "{}" }, "model": "pages.LiveEvent", "pk": 200 }, +{ + "fields": { + "element": 107, + "created_at": "2023-03-15T10:00:00Z", + "scheduled_at": "2023-04-10T15:00:00Z", + "index": 2, + "location": "http://127.0.0.1:8000/webinar/sustainability", + "max_attendees": 10, + "extra": "{}" + }, + "model": "pages.LiveEvent", "pk": 201 +}, { "fields": { "sequence": 103, From 22ff2575137a1b4627f5bbddc0a0bc5edf194a65 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Fri, 9 Feb 2024 04:29:17 -0800 Subject: [PATCH 3/8] [Work-in-Progress] Fixes to LiveEvent Currently working on: - fixing issue where EnumeratedProgress is not keeping track of the exact LiveEvent. Current approach is to add an extra field in the EnumeratedProgress model and use that. - Ensuring the delete method changes the status of the LiveEvent and doesn't delete it - Correct URL locations --- pages/api/progress.py | 99 +++++++++++++++++++++++-------- pages/api/sequences.py | 32 +++++----- pages/models.py | 17 +++--- pages/serializers.py | 22 ++++--- pages/urls/api/editables.py | 10 ++-- pages/urls/api/sequences.py | 7 ++- testsite/fixtures/default-db.json | 4 +- 7 files changed, 129 insertions(+), 62 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index 477083d..cf97bc8 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -199,6 +199,8 @@ def post(self, request, *args, **kwargs): serializer = self.get_serializer(instance) return api_response.Response(serializer.data, status=status_code) +from django.http import Http404 +from django.shortcuts import get_object_or_404 class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView): """ @@ -223,10 +225,24 @@ class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView): } """ rank_url_kwarg = 'rank' + event_rank_kwarg = 'event_rank' + + def get_object(self): + return self.progress def get_serializer_class(self): return super(LiveEventAttendanceAPIView, self).get_serializer_class() + def get(self, request, *args, **kwargs): + progress = self.progress + live_event_rank = get_extra(progress, "live_event_rank") + if live_event_rank == self.kwargs.get(self.event_rank_kwarg): + serializer = self.get_serializer(progress) + return api_response.Response(serializer.data) + else: + raise Http404() + + def post(self, request, *args, **kwargs): """ Marks a user's attendance to a live event @@ -250,30 +266,65 @@ def post(self, request, *args, **kwargs): "detail": "Attendance marked successfully" } """ - # Get the LiveEvent using request.data - index = request.data.get('index', None) + + event_rank = kwargs.get(self.event_rank_kwarg) progress = self.get_object() element = progress.step - if index: - live_event = LiveEvent.objects.filter( - element=element.content, index=index).first() - - # We use if live_event to confirm the existence of the LiveEvent object - if (live_event and - progress.viewing_duration < element.min_viewing_duration): - progress.viewing_duration = element.min_viewing_duration - progress.save() - return api_response.Response( - {'detail': 'Attendance marked successfully'}, - status=status.HTTP_200_OK) - # To prevent marking attendance continuously on a LiveEvent - # that has already been marked - elif (live_event and - progress.viewing_duration == element.min_viewing_duration) and ( - element.min_viewing_duration > 0): - return api_response.Response( - {'detail': 'Attendance already marked'}, - status=status.HTTP_200_OK) + live_event = get_object_or_404(LiveEvent, element=element.content, rank=event_rank) + + if live_event.scheduled_at >= datetime_or_now(): + return api_response.Response( + {'detail': 'Attendance not marked'}, + status=status.HTTP_400_BAD_REQUEST) + + extra = json.loads(progress.extra) if progress.extra else {} + extra["live_event_rank"] = event_rank + + if get_extra(progress, "live_event_rank") == event_rank and progress.viewing_duration >= progress.step.min_viewing_duration: + progress.extra = json.dumps(extra) + progress.save() + return api_response.Response({'detail': 'Attendance already marked'}, status=status.HTTP_200_OK) + if progress.viewing_duration < progress.step.min_viewing_duration: + progress.viewing_duration = progress.step.min_viewing_duration + progress.extra = json.dumps(extra) + progress.save() return api_response.Response( - {'detail': 'Attendance not marked'}, - status=status.HTTP_400_BAD_REQUEST) + {'detail': 'Attendance marked successfully'}, status=status.HTTP_200_OK) + +from ..models import Sequence +from ..serializers import LiveEventAttendeesSerializer +from ..mixins import SequenceMixin +import json +from ..helpers import get_extra + +class LiveEventAttendeesAPIView(SequenceMixin, ListAPIView): + serializer_class = LiveEventAttendeesSerializer + + rank_url_kwarg = 'rank' + event_rank_kwarg = 'event_rank' + + def get_queryset(self): + element_rank = self.kwargs.get(self.rank_url_kwarg) + event_rank = self.kwargs.get(self.event_rank_kwarg) + + element = EnumeratedElements.objects.filter( + sequence=self.sequence, rank=element_rank + ).first() + + live_event = element.content.events.filter( + rank=event_rank).first() + + queryset = EnumeratedProgress.objects.filter( + step__content=element.content, + viewing_duration__gte=element.min_viewing_duration) + if live_event: + progress_ids = [progress.id for progress in queryset + if get_extra(progress, 'live_event_rank') == live_event.rank] + queryset = queryset.filter(id__in=progress_ids) + + return queryset + return EnumeratedProgress.objects.none() + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + diff --git a/pages/api/sequences.py b/pages/api/sequences.py index ca39cb0..b6f2b42 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -437,38 +437,40 @@ def perform_destroy(self, instance): instance.delete() -class LiveEventListCreatePIView(AccountMixin, PageElementMixin, ListCreateAPIView): - # Should this be in editables(?) +class LiveEventListCreateAPIView(AccountMixin, PageElementMixin, ListCreateAPIView): + ''' + Lists Live Events belonging to a Page Element + ''' serializer_class = LiveEventSerializer def get_queryset(self): - queryset = LiveEvent.objects.all() + queryset = LiveEvent.objects.all().order_by('rank') if self.element_url_kwarg in self.kwargs: queryset = queryset.filter(element=self.element) - if self.account_url_kwarg in self.kwargs: - # Won't be necessary in the front-end view so the - # if statement helps - queryset = queryset.filter(element__account=self.account) return queryset def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) def post(self, request, *args, **kwargs): - # We're asking for an Element here again(via the Serializer), - # even though we've ascribed an element in the URL return self.create(request, *args, **kwargs) + def perform_create(self, serializer): + serializer.save( + element=self.element, status='active') + -class LiveEventDeleteAPIView(PageElementMixin, DestroyAPIView): - # Deletes a LiveEvent +class LiveEventRetrieveUpdateDestroyAPIView(PageElementMixin, RetrieveUpdateDestroyAPIView): + ''' + Retrieve a LiveEvent + ''' + serializer_class = LiveEventSerializer + # Doesn't allow editing the Status field because it is a read_only + # field in the Serializer. def get_object(self): return get_object_or_404(LiveEvent.objects.all(), - element=self.element, index=self.kwargs.get('index')) + element=self.element, rank=self.kwargs.get('rank')) def delete(self, request, *args, **kwargs): - # How do we deal with the fact that this removes - # the live event but not the enumeratedelement which - # might not have any attached liveevents? return self.destroy(self, request, *args, **kwargs) diff --git a/pages/models.py b/pages/models.py index 3219ca0..a9ab01b 100644 --- a/pages/models.py +++ b/pages/models.py @@ -389,19 +389,21 @@ class LiveEvent(models.Model): help_text=_("Date/time the live event was created (in ISO format)")) scheduled_at = models.DateTimeField( help_text=_("Date/time the live event is scheduled (in ISO format)")) - index = models.PositiveSmallIntegerField(default=1, + rank = models.PositiveSmallIntegerField(default=1, help_text="Unique integer to denote the index of the LiveEvent") location = models.URLField(_("URL to the calendar event"), max_length=2083) max_attendees = models.IntegerField(default=0) + status = models.CharField( + choices=(('active', 'Active'), ('cancelled', 'Cancelled'), + ('updated', 'Updated')), max_length=9) extra = get_extra_field_class()(null=True, blank=True, help_text=_("Extra meta data (can be stringify JSON)")) - # Maybe we can add "status" to the extra and use it to filter - # live events for ones that are active or a new status field + def __str__(self): - return "%s-live" % str(self.element) + return "%s-live-%s" % (str(self.element), str(self.rank)) class Meta: - unique_together = ('element', 'index') + unique_together = ('element', 'rank') @python_2_unicode_compatible @@ -514,9 +516,10 @@ class EnumeratedProgress(models.Model): default=datetime.timedelta, # stored in microseconds help_text=_("Total recorded viewing time for the material")) last_ping_time = models.DateTimeField( - null=True, - blank=True, + null=True, blank=True, help_text=_("Timestamp of the last activity ping")) + extra = get_extra_field_class()(null=True, blank=True, + help_text=_("Extra meta data (can be stringify JSON)")) class Meta: unique_together = ('sequence_progress', 'step') diff --git a/pages/serializers.py b/pages/serializers.py index aac47f8..306b0de 100644 --- a/pages/serializers.py +++ b/pages/serializers.py @@ -379,20 +379,28 @@ class Meta(EnumeratedElementSerializer.Meta): class LiveEventSerializer(serializers.ModelSerializer): - element = serializers.SlugRelatedField( - queryset=PageElement.objects.all(), - slug_field="slug", - help_text=_('LiveEvent the enumerated element is for'), - required=True) scheduled_at = serializers.DateTimeField( help_text=_('Date/time the live event is scheduled')) - index = serializers.IntegerField( + rank = serializers.IntegerField( help_text=_('Unique integer to denote the index of the LiveEvent')) location = serializers.URLField( help_text=_('URL to the live event')) max_attendees = serializers.IntegerField(default=0, help_text=_('Max attendees for the LiveEvent')) + status = serializers.CharField(read_only=True) class Meta: model = LiveEvent - fields = ('element', 'scheduled_at', 'index', 'location', 'max_attendees') + fields = ('scheduled_at', 'rank', 'location', 'max_attendees', 'status') + read_only_fields = ('status',) + +class LiveEventAttendeesSerializer(EnumeratedProgressSerializer): + user = serializers.SerializerMethodField( + help_text=_("Username of the attendee")) + + class Meta(EnumeratedProgressSerializer.Meta): + fields = EnumeratedProgressSerializer.Meta.fields + ('user',) + read_only_fields = EnumeratedProgressSerializer.Meta.read_only_fields + ('user',) + + def get_user(self, obj): + return obj.sequence_progress.user.username diff --git a/pages/urls/api/editables.py b/pages/urls/api/editables.py index 97dc3ab..0eb94c8 100644 --- a/pages/urls/api/editables.py +++ b/pages/urls/api/editables.py @@ -34,17 +34,17 @@ from ...api.sequences import (SequenceListCreateAPIView, SequenceRetrieveUpdateDestroyAPIView, RemoveElementFromSequenceAPIView, AddElementToSequenceAPIView, - LiveEventListCreatePIView, LiveEventDeleteAPIView) + LiveEventListCreateAPIView, LiveEventRetrieveUpdateDestroyAPIView) urlpatterns = [ # Move these live event URLs with PageElement URLs # They also have the account in the URL, not necessary? - path('elements//live-events/', - LiveEventDeleteAPIView.as_view(), + path('/live-events/', + LiveEventRetrieveUpdateDestroyAPIView.as_view(), name='api_live_event_destroy'), - path('elements//live-events', - LiveEventListCreatePIView.as_view(), + path('/live-events', + LiveEventListCreateAPIView.as_view(), name='api_live_event_list_create'), path(r'sequences//elements/', diff --git a/pages/urls/api/sequences.py b/pages/urls/api/sequences.py index 4c5b7c0..aaa770e 100644 --- a/pages/urls/api/sequences.py +++ b/pages/urls/api/sequences.py @@ -27,14 +27,17 @@ """ from ...api.progress import (EnumeratedProgressResetAPIView, - LiveEventAttendanceAPIView) + LiveEventAttendanceAPIView, LiveEventAttendeesAPIView) from ...compat import path urlpatterns = [ - path('///', + path('///attendees/', LiveEventAttendanceAPIView.as_view(), name='api_mark_attendance'), + path('///attendees', + LiveEventAttendeesAPIView.as_view(), + name='api_live_event_attendees'), path('/', EnumeratedProgressResetAPIView.as_view(), name='api_progress_reset'), diff --git a/testsite/fixtures/default-db.json b/testsite/fixtures/default-db.json index dd33479..7c68403 100644 --- a/testsite/fixtures/default-db.json +++ b/testsite/fixtures/default-db.json @@ -234,7 +234,7 @@ "element": 107, "created_at": "2023-03-15T10:00:00Z", "scheduled_at": "2023-04-10T15:00:00Z", - "index": 1, + "rank": 1, "location": "http://127.0.0.1:8000/webinar/sustainability", "max_attendees": 100, "extra": "{}" @@ -246,7 +246,7 @@ "element": 107, "created_at": "2023-03-15T10:00:00Z", "scheduled_at": "2023-04-10T15:00:00Z", - "index": 2, + "rank": 2, "location": "http://127.0.0.1:8000/webinar/sustainability", "max_attendees": 10, "extra": "{}" From 130ca532217341ce6441cf73b1075f9eb1493071 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Tue, 13 Feb 2024 03:51:45 -0800 Subject: [PATCH 4/8] Updates LiveEvent APIs, serializers, and models Added soft delete to LiveEvents, updates to attendee retrieval logic and more. Notes: - Need further clarification on URL patterns. - Not sure if a soft delete is required for LiveEvent attendance. - A few comments in there, to be removed before merge. --- pages/api/progress.py | 100 +++++++++++++++++++++--------------- pages/api/sequences.py | 19 ++++--- pages/models.py | 5 +- pages/serializers.py | 3 +- pages/urls/api/editables.py | 3 +- 5 files changed, 77 insertions(+), 53 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index cf97bc8..f3f227e 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -22,17 +22,21 @@ # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from datetime import timedelta +import json, datetime from deployutils.helpers import datetime_or_now +from django.shortcuts import get_object_or_404 +from django.http import Http404 from rest_framework import response as api_response, status -from rest_framework.generics import DestroyAPIView, ListAPIView, RetrieveAPIView +from rest_framework.generics import (DestroyAPIView, ListAPIView, RetrieveAPIView) from .. import settings from ..docs import extend_schema -from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin +from ..helpers import get_extra +from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin, SequenceMixin from ..models import EnumeratedElements, EnumeratedProgress, LiveEvent -from ..serializers import (EnumeratedProgressSerializer, LiveEventSerializer) +from ..serializers import (EnumeratedProgressSerializer, + LiveEventAttendeesSerializer) class EnumeratedProgressListAPIView(SequenceProgressMixin, ListAPIView): @@ -100,8 +104,8 @@ def paginate_queryset(self, queryset): results = page if page else queryset for elem in results: if (elem.viewing_duration is not None and - not isinstance(elem.viewing_duration, timedelta)): - elem.viewing_duration = timedelta( + not isinstance(elem.viewing_duration, datetime.timedelta)): + elem.viewing_duration = datetime.timedelta( microseconds=elem.viewing_duration) return results @@ -186,10 +190,10 @@ def post(self, request, *args, **kwargs): if instance.last_ping_time: time_elapsed = now - instance.last_ping_time # Add only the actual time elapsed, with a cap for inactivity - time_increment = min(time_elapsed, timedelta(seconds=settings.PING_INTERVAL+1)) + time_increment = min(time_elapsed, datetime.timedelta(seconds=settings.PING_INTERVAL+1)) else: # Set the initial increment to the expected ping interval (i.e., 10 seconds) - time_increment = timedelta(seconds=settings.PING_INTERVAL) + time_increment = datetime.timedelta(seconds=settings.PING_INTERVAL) instance.viewing_duration += time_increment instance.last_ping_time = now @@ -199,10 +203,8 @@ def post(self, request, *args, **kwargs): serializer = self.get_serializer(instance) return api_response.Response(serializer.data, status=status_code) -from django.http import Http404 -from django.shortcuts import get_object_or_404 -class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView): +class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView, DestroyAPIView): """ Retrieves attendance to live event @@ -234,14 +236,16 @@ def get_serializer_class(self): return super(LiveEventAttendanceAPIView, self).get_serializer_class() def get(self, request, *args, **kwargs): + # Need to ensure the EnumeratedProgress matches the correct LiveEvent + # As it currently returns any EnumeratedProgress on the PageElement. progress = self.progress live_event_rank = get_extra(progress, "live_event_rank") - if live_event_rank == self.kwargs.get(self.event_rank_kwarg): - serializer = self.get_serializer(progress) - return api_response.Response(serializer.data) - else: - raise Http404() + if live_event_rank != self.kwargs.get(self.event_rank_kwarg): + raise Http404 + + serializer = self.get_serializer(progress) + return api_response.Response(serializer.data) def post(self, request, *args, **kwargs): """ @@ -270,32 +274,50 @@ def post(self, request, *args, **kwargs): event_rank = kwargs.get(self.event_rank_kwarg) progress = self.get_object() element = progress.step - live_event = get_object_or_404(LiveEvent, element=element.content, rank=event_rank) + live_event = get_object_or_404( + LiveEvent, element=element.content, rank=event_rank) if live_event.scheduled_at >= datetime_or_now(): return api_response.Response( - {'detail': 'Attendance not marked'}, + {'detail': 'Can not mark attendance for an event at a future date'}, status=status.HTTP_400_BAD_REQUEST) extra = json.loads(progress.extra) if progress.extra else {} - extra["live_event_rank"] = event_rank - if get_extra(progress, "live_event_rank") == event_rank and progress.viewing_duration >= progress.step.min_viewing_duration: - progress.extra = json.dumps(extra) - progress.save() - return api_response.Response({'detail': 'Attendance already marked'}, status=status.HTTP_200_OK) + if get_extra(progress, "live_event_rank") == event_rank and \ + progress.viewing_duration >= progress.step.min_viewing_duration: + + return api_response.Response({'detail': 'Attendance already marked'}, + status=status.HTTP_400_BAD_REQUEST) + if progress.viewing_duration < progress.step.min_viewing_duration: progress.viewing_duration = progress.step.min_viewing_duration + + extra["live_event_rank"] = event_rank progress.extra = json.dumps(extra) progress.save() return api_response.Response( - {'detail': 'Attendance marked successfully'}, status=status.HTTP_200_OK) + {'detail': 'Attendance marked successfully'}, status=status.HTTP_201_CREATED) + + def delete(self, request, *args, **kwargs): + ''' + Resets Live Event Attendance + ''' + # Maybe redundant because we're already resetting EnumeratedProgress? + event_rank = kwargs.get(self.event_rank_kwarg) + progress = self.get_object() + + extra = json.loads(progress.extra) if progress.extra else {} + + if get_extra(progress, "live_event_rank") == event_rank: + extra["live_event_rank"] = "" + progress.extra = json.dumps(extra) + progress.viewing_duration = datetime.timedelta(seconds=0) + progress.save() + return api_response.Response(status=status.HTTP_204_NO_CONTENT) + + return api_response.Response(status=status.HTTP_400_BAD_REQUEST) -from ..models import Sequence -from ..serializers import LiveEventAttendeesSerializer -from ..mixins import SequenceMixin -import json -from ..helpers import get_extra class LiveEventAttendeesAPIView(SequenceMixin, ListAPIView): serializer_class = LiveEventAttendeesSerializer @@ -307,23 +329,17 @@ def get_queryset(self): element_rank = self.kwargs.get(self.rank_url_kwarg) event_rank = self.kwargs.get(self.event_rank_kwarg) - element = EnumeratedElements.objects.filter( - sequence=self.sequence, rank=element_rank - ).first() - - live_event = element.content.events.filter( - rank=event_rank).first() + element = get_object_or_404( + EnumeratedElements, sequence=self.sequence, rank=element_rank) queryset = EnumeratedProgress.objects.filter( step__content=element.content, viewing_duration__gte=element.min_viewing_duration) - if live_event: - progress_ids = [progress.id for progress in queryset - if get_extra(progress, 'live_event_rank') == live_event.rank] - queryset = queryset.filter(id__in=progress_ids) - - return queryset - return EnumeratedProgress.objects.none() + + progress_ids = [progress.id for progress in queryset if + get_extra(progress, "live_event_rank") == event_rank] + queryset = queryset.filter(id__in=progress_ids) + return queryset def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) diff --git a/pages/api/sequences.py b/pages/api/sequences.py index b6f2b42..041d443 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -444,9 +444,11 @@ class LiveEventListCreateAPIView(AccountMixin, PageElementMixin, ListCreateAPIVi serializer_class = LiveEventSerializer def get_queryset(self): - queryset = LiveEvent.objects.all().order_by('rank') - if self.element_url_kwarg in self.kwargs: - queryset = queryset.filter(element=self.element) + queryset = LiveEvent.objects.filter( + element=self.element + ).order_by('-status', 'rank') + # if self.element_url_kwarg in self.kwargs: + # queryset = queryset.filter(element=self.element) return queryset def get(self, request, *args, **kwargs): @@ -456,8 +458,7 @@ def post(self, request, *args, **kwargs): return self.create(request, *args, **kwargs) def perform_create(self, serializer): - serializer.save( - element=self.element, status='active') + serializer.save(element=self.element, status='scheduled') class LiveEventRetrieveUpdateDestroyAPIView(PageElementMixin, RetrieveUpdateDestroyAPIView): @@ -466,11 +467,15 @@ class LiveEventRetrieveUpdateDestroyAPIView(PageElementMixin, RetrieveUpdateDest ''' serializer_class = LiveEventSerializer # Doesn't allow editing the Status field because it is a read_only - # field in the Serializer. + # field in the Serializer since we're using the Delete method + # to set it. def get_object(self): return get_object_or_404(LiveEvent.objects.all(), element=self.element, rank=self.kwargs.get('rank')) def delete(self, request, *args, **kwargs): - return self.destroy(self, request, *args, **kwargs) + live_event = self.get_object() + live_event.status = 'cancelled' + live_event.save() + return api_response.Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/pages/models.py b/pages/models.py index a9ab01b..39f585e 100644 --- a/pages/models.py +++ b/pages/models.py @@ -394,8 +394,9 @@ class LiveEvent(models.Model): location = models.URLField(_("URL to the calendar event"), max_length=2083) max_attendees = models.IntegerField(default=0) status = models.CharField( - choices=(('active', 'Active'), ('cancelled', 'Cancelled'), - ('updated', 'Updated')), max_length=9) + max_length=9, + choices=(('scheduled', 'Scheduled'), ('cancelled', 'Cancelled')), + default='scheduled', help_text=_("Current status of the LiveEvent")) extra = get_extra_field_class()(null=True, blank=True, help_text=_("Extra meta data (can be stringify JSON)")) diff --git a/pages/serializers.py b/pages/serializers.py index 306b0de..01ac929 100644 --- a/pages/serializers.py +++ b/pages/serializers.py @@ -394,12 +394,13 @@ class Meta: fields = ('scheduled_at', 'rank', 'location', 'max_attendees', 'status') read_only_fields = ('status',) + class LiveEventAttendeesSerializer(EnumeratedProgressSerializer): user = serializers.SerializerMethodField( help_text=_("Username of the attendee")) class Meta(EnumeratedProgressSerializer.Meta): - fields = EnumeratedProgressSerializer.Meta.fields + ('user',) + fields = ('user', 'content', 'viewing_duration', 'min_viewing_duration') read_only_fields = EnumeratedProgressSerializer.Meta.read_only_fields + ('user',) def get_user(self, obj): diff --git a/pages/urls/api/editables.py b/pages/urls/api/editables.py index 0eb94c8..257fef6 100644 --- a/pages/urls/api/editables.py +++ b/pages/urls/api/editables.py @@ -39,7 +39,8 @@ urlpatterns = [ # Move these live event URLs with PageElement URLs - # They also have the account in the URL, not necessary? + # They also have the account in the URL, how to check + # access? path('/live-events/', LiveEventRetrieveUpdateDestroyAPIView.as_view(), name='api_live_event_destroy'), From 95b9d9c2d8ba74e9af3d978bdf35d4005ba27701 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Fri, 16 Feb 2024 02:04:18 -0800 Subject: [PATCH 5/8] Cleans up logic to mark Live Event attendance --- pages/api/progress.py | 53 +++++++++++++++++++++++----------------- pages/api/sequences.py | 13 +++------- pages/models.py | 9 +++++-- pages/views/sequences.py | 2 +- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index f3f227e..eeb966b 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -29,6 +29,7 @@ from django.http import Http404 from rest_framework import response as api_response, status from rest_framework.generics import (DestroyAPIView, ListAPIView, RetrieveAPIView) +from rest_framework.mixins import DestroyModelMixin from .. import settings from ..docs import extend_schema @@ -204,7 +205,7 @@ def post(self, request, *args, **kwargs): return api_response.Response(serializer.data, status=status_code) -class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView, DestroyAPIView): +class LiveEventAttendanceAPIView(DestroyModelMixin, EnumeratedProgressRetrieveAPIView): """ Retrieves attendance to live event @@ -229,11 +230,7 @@ class LiveEventAttendanceAPIView(EnumeratedProgressRetrieveAPIView, DestroyAPIVi rank_url_kwarg = 'rank' event_rank_kwarg = 'event_rank' - def get_object(self): - return self.progress - - def get_serializer_class(self): - return super(LiveEventAttendanceAPIView, self).get_serializer_class() + serializer_class = EnumeratedProgressSerializer def get(self, request, *args, **kwargs): # Need to ensure the EnumeratedProgress matches the correct LiveEvent @@ -245,7 +242,8 @@ def get(self, request, *args, **kwargs): raise Http404 serializer = self.get_serializer(progress) - return api_response.Response(serializer.data) + return api_response.Response( + serializer.data, status=status.HTTP_200_OK) def post(self, request, *args, **kwargs): """ @@ -276,38 +274,49 @@ def post(self, request, *args, **kwargs): element = progress.step live_event = get_object_or_404( LiveEvent, element=element.content, rank=event_rank) + at_time = datetime_or_now() - if live_event.scheduled_at >= datetime_or_now(): + progress_event_rank = get_extra(progress, "live_event_rank") + if progress_event_rank and progress_event_rank != event_rank: return api_response.Response( - {'detail': 'Can not mark attendance for an event at a future date'}, + {'detail': 'Attendance already marked for a different ' + 'Live Event on this PageElement'}, status=status.HTTP_400_BAD_REQUEST) - extra = json.loads(progress.extra) if progress.extra else {} - - if get_extra(progress, "live_event_rank") == event_rank and \ - progress.viewing_duration >= progress.step.min_viewing_duration: + if live_event.scheduled_at >= at_time: + return api_response.Response( + {'detail': 'Can not mark attendance for future events'}, + status=status.HTTP_400_BAD_REQUEST) + + extra = json.loads(progress.extra) if isinstance( + progress.extra, str) else progress.extra or {} - return api_response.Response({'detail': 'Attendance already marked'}, - status=status.HTTP_400_BAD_REQUEST) + if progress.viewing_duration >= element.min_viewing_duration: + return api_response.Response( + {'detail': 'Attendance already marked'}, + status=status.HTTP_400_BAD_REQUEST) - if progress.viewing_duration < progress.step.min_viewing_duration: - progress.viewing_duration = progress.step.min_viewing_duration + progress.viewing_duration = max( + progress.viewing_duration, element.min_viewing_duration) extra["live_event_rank"] = event_rank progress.extra = json.dumps(extra) progress.save() return api_response.Response( - {'detail': 'Attendance marked successfully'}, status=status.HTTP_201_CREATED) - + {'detail': 'Attendance marked successfully'}, + status=status.HTTP_201_CREATED) + def delete(self, request, *args, **kwargs): ''' Resets Live Event Attendance ''' - # Maybe redundant because we're already resetting EnumeratedProgress? + # Maybe redundant because we're already resetting EnumeratedProgress + # in EnumeratedProgressResetAPIView? event_rank = kwargs.get(self.event_rank_kwarg) progress = self.get_object() - extra = json.loads(progress.extra) if progress.extra else {} + extra = json.loads(progress.extra) if isinstance( + progress.extra, str) else progress.extra or {} if get_extra(progress, "live_event_rank") == event_rank: extra["live_event_rank"] = "" @@ -342,5 +351,5 @@ def get_queryset(self): return queryset def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) + return super(LiveEventAttendeesAPIView, self).get(request, *args, **kwargs) diff --git a/pages/api/sequences.py b/pages/api/sequences.py index 041d443..b043370 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -404,10 +404,6 @@ def post(self, request, *args, **kwargs): {'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) - # return api_response.Response( - # serializer.errors, - # status=status.HTTP_400_BAD_REQUEST) - class RemoveElementFromSequenceAPIView(AccountMixin, SequenceMixin, DestroyAPIView): @@ -447,15 +443,14 @@ def get_queryset(self): queryset = LiveEvent.objects.filter( element=self.element ).order_by('-status', 'rank') - # if self.element_url_kwarg in self.kwargs: - # queryset = queryset.filter(element=self.element) + return queryset def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) + return super(LiveEventListCreateAPIView, self).get(request, *args, **kwargs) def post(self, request, *args, **kwargs): - return self.create(request, *args, **kwargs) + return super(LiveEventListCreateAPIView, self).post(request, *args, **kwargs) def perform_create(self, serializer): serializer.save(element=self.element, status='scheduled') @@ -476,6 +471,6 @@ def get_object(self): def delete(self, request, *args, **kwargs): live_event = self.get_object() - live_event.status = 'cancelled' + live_event.status = 'CANCELLED' live_event.save() return api_response.Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/pages/models.py b/pages/models.py index 39f585e..affabf9 100644 --- a/pages/models.py +++ b/pages/models.py @@ -383,6 +383,11 @@ class LiveEvent(models.Model): """ A live webinar, onsite classroom, etc. """ + EVENT_STATUS_CHOICES = ( + ('SCHEDULED', 'Scheduled'), + ('CANCELLED', 'Cancelled') + ) + element = models.ForeignKey(PageElement, on_delete=models.CASCADE, related_name='events') created_at = models.DateTimeField(editable=False, auto_now_add=True, @@ -395,8 +400,8 @@ class LiveEvent(models.Model): max_attendees = models.IntegerField(default=0) status = models.CharField( max_length=9, - choices=(('scheduled', 'Scheduled'), ('cancelled', 'Cancelled')), - default='scheduled', help_text=_("Current status of the LiveEvent")) + choices=EVENT_STATUS_CHOICES, default='SCHEDULED', + help_text=_("Current status of the LiveEvent")) extra = get_extra_field_class()(null=True, blank=True, help_text=_("Extra meta data (can be stringify JSON)")) diff --git a/pages/views/sequences.py b/pages/views/sequences.py index 206f60d..90387cd 100644 --- a/pages/views/sequences.py +++ b/pages/views/sequences.py @@ -71,7 +71,7 @@ def get_context_data(self, **kwargs): class SequencePageElementView(EnumeratedProgressMixin, TemplateView): template_name = 'pages/app/sequences/pageelement.html' - # is_live_event and is_certificate flags not being added + def get_context_data(self, **kwargs): #pylint:disable=too-many-locals context = super(SequencePageElementView, From ac519cec66d2b621c2335a0cab5938f52a41aae0 Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Mon, 19 Feb 2024 17:45:06 -0800 Subject: [PATCH 6/8] Minor updates --- pages/api/sequences.py | 4 ++-- pages/models.py | 9 ++++++--- pages/serializers.py | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pages/api/sequences.py b/pages/api/sequences.py index b043370..bccec2c 100644 --- a/pages/api/sequences.py +++ b/pages/api/sequences.py @@ -453,7 +453,7 @@ def post(self, request, *args, **kwargs): return super(LiveEventListCreateAPIView, self).post(request, *args, **kwargs) def perform_create(self, serializer): - serializer.save(element=self.element, status='scheduled') + serializer.save(element=self.element, status=LiveEvent.SCHEDULED) class LiveEventRetrieveUpdateDestroyAPIView(PageElementMixin, RetrieveUpdateDestroyAPIView): @@ -471,6 +471,6 @@ def get_object(self): def delete(self, request, *args, **kwargs): live_event = self.get_object() - live_event.status = 'CANCELLED' + live_event.status = LiveEvent.CANCELLED live_event.save() return api_response.Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/pages/models.py b/pages/models.py index affabf9..80db861 100644 --- a/pages/models.py +++ b/pages/models.py @@ -383,9 +383,12 @@ class LiveEvent(models.Model): """ A live webinar, onsite classroom, etc. """ + CANCELLED = "Cancelled" + SCHEDULED = "Scheduled" + EVENT_STATUS_CHOICES = ( - ('SCHEDULED', 'Scheduled'), - ('CANCELLED', 'Cancelled') + (CANCELLED, 'Cancelled'), + (SCHEDULED, 'Scheduled') ) element = models.ForeignKey(PageElement, on_delete=models.CASCADE, @@ -400,7 +403,7 @@ class LiveEvent(models.Model): max_attendees = models.IntegerField(default=0) status = models.CharField( max_length=9, - choices=EVENT_STATUS_CHOICES, default='SCHEDULED', + choices=EVENT_STATUS_CHOICES, default=SCHEDULED, help_text=_("Current status of the LiveEvent")) extra = get_extra_field_class()(null=True, blank=True, help_text=_("Extra meta data (can be stringify JSON)")) diff --git a/pages/serializers.py b/pages/serializers.py index 01ac929..21c200a 100644 --- a/pages/serializers.py +++ b/pages/serializers.py @@ -387,7 +387,8 @@ class LiveEventSerializer(serializers.ModelSerializer): help_text=_('URL to the live event')) max_attendees = serializers.IntegerField(default=0, help_text=_('Max attendees for the LiveEvent')) - status = serializers.CharField(read_only=True) + status = serializers.CharField( + help_text=_("Current status of the LiveEvent")) class Meta: model = LiveEvent From 0c40bc069a4490bec52c762dc3e9a37e7e5f3fa5 Mon Sep 17 00:00:00 2001 From: Noman Bukhari <65825972+Deep-Chill@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:27:59 -0800 Subject: [PATCH 7/8] Implements set_extra and other refactors Uses the set_extra method. It initializes extra as a dictionary if it doesn't exist. An alternative could be to initialize extra as and empty string if a progress object is created in `Mixins.py` on `line 333`. --- pages/api/progress.py | 81 ++++++++++++++++++------------------------- pages/helpers.py | 14 ++++++++ pages/mixins.py | 2 +- pages/models.py | 2 +- 4 files changed, 50 insertions(+), 49 deletions(-) diff --git a/pages/api/progress.py b/pages/api/progress.py index eeb966b..7fa2e88 100644 --- a/pages/api/progress.py +++ b/pages/api/progress.py @@ -22,7 +22,7 @@ # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import json, datetime +import datetime from deployutils.helpers import datetime_or_now from django.shortcuts import get_object_or_404 @@ -33,10 +33,10 @@ from .. import settings from ..docs import extend_schema -from ..helpers import get_extra +from ..helpers import get_extra, set_extra from ..mixins import EnumeratedProgressMixin, SequenceProgressMixin, SequenceMixin from ..models import EnumeratedElements, EnumeratedProgress, LiveEvent -from ..serializers import (EnumeratedProgressSerializer, +from ..serializers import (EnumeratedProgressSerializer, LiveEventAttendeesSerializer) @@ -105,7 +105,7 @@ def paginate_queryset(self, queryset): results = page if page else queryset for elem in results: if (elem.viewing_duration is not None and - not isinstance(elem.viewing_duration, datetime.timedelta)): + not isinstance(elem.viewing_duration, datetime.timedelta)): elem.viewing_duration = datetime.timedelta( microseconds=elem.viewing_duration) return results @@ -200,9 +200,8 @@ def post(self, request, *args, **kwargs): instance.last_ping_time = now instance.save() - status_code = status.HTTP_200_OK serializer = self.get_serializer(instance) - return api_response.Response(serializer.data, status=status_code) + return api_response.Response(serializer.data, status=status.HTTP_200_OK) class LiveEventAttendanceAPIView(DestroyModelMixin, EnumeratedProgressRetrieveAPIView): @@ -268,44 +267,33 @@ def post(self, request, *args, **kwargs): "detail": "Attendance marked successfully" } """ - + at_time = datetime_or_now() event_rank = kwargs.get(self.event_rank_kwarg) progress = self.get_object() element = progress.step live_event = get_object_or_404( LiveEvent, element=element.content, rank=event_rank) - at_time = datetime_or_now() - - progress_event_rank = get_extra(progress, "live_event_rank") - if progress_event_rank and progress_event_rank != event_rank: - return api_response.Response( - {'detail': 'Attendance already marked for a different ' - 'Live Event on this PageElement'}, - status=status.HTTP_400_BAD_REQUEST) - if live_event.scheduled_at >= at_time: - return api_response.Response( - {'detail': 'Can not mark attendance for future events'}, - status=status.HTTP_400_BAD_REQUEST) - - extra = json.loads(progress.extra) if isinstance( - progress.extra, str) else progress.extra or {} - - if progress.viewing_duration >= element.min_viewing_duration: + progress_event_rank = set_extra( + progress, "live_event_rank", event_rank) + + if progress.viewing_duration < element.min_viewing_duration and live_event.scheduled_at < at_time: + if progress_event_rank and progress_event_rank != event_rank: + return api_response.Response( + {'detail': f'Attendance already marked for Live Event ' + f'with rank: {event_rank}'}, + status=status.HTTP_400_BAD_REQUEST) + progress.viewing_duration = max( + progress.viewing_duration, element.min_viewing_duration) + progress.save() + serializer = self.get_serializer(progress) return api_response.Response( - {'detail': 'Attendance already marked'}, - status=status.HTTP_400_BAD_REQUEST) - - progress.viewing_duration = max( - progress.viewing_duration, element.min_viewing_duration) + serializer.data, status=status.HTTP_201_CREATED) - extra["live_event_rank"] = event_rank - progress.extra = json.dumps(extra) - progress.save() return api_response.Response( - {'detail': 'Attendance marked successfully'}, - status=status.HTTP_201_CREATED) - + {'detail': 'Attendance not marked'}, + status=status.HTTP_400_BAD_REQUEST) + def delete(self, request, *args, **kwargs): ''' Resets Live Event Attendance @@ -315,17 +303,16 @@ def delete(self, request, *args, **kwargs): event_rank = kwargs.get(self.event_rank_kwarg) progress = self.get_object() - extra = json.loads(progress.extra) if isinstance( - progress.extra, str) else progress.extra or {} + curr_val = set_extra( + progress, "live_event_rank", '') - if get_extra(progress, "live_event_rank") == event_rank: - extra["live_event_rank"] = "" - progress.extra = json.dumps(extra) - progress.viewing_duration = datetime.timedelta(seconds=0) - progress.save() - return api_response.Response(status=status.HTTP_204_NO_CONTENT) + if curr_val != event_rank: + return api_response.Response( + status=status.HTTP_400_BAD_REQUEST) - return api_response.Response(status=status.HTTP_400_BAD_REQUEST) + progress.viewing_duration = datetime.timedelta(seconds=0) + progress.save() + return api_response.Response(status=status.HTTP_204_NO_CONTENT) class LiveEventAttendeesAPIView(SequenceMixin, ListAPIView): @@ -344,9 +331,9 @@ def get_queryset(self): queryset = EnumeratedProgress.objects.filter( step__content=element.content, viewing_duration__gte=element.min_viewing_duration) - - progress_ids = [progress.id for progress in queryset if - get_extra(progress, "live_event_rank") == event_rank] + + progress_ids = [progress.id for progress in queryset if + get_extra(progress, "live_event_rank") == event_rank] queryset = queryset.filter(id__in=progress_ids) return queryset diff --git a/pages/helpers.py b/pages/helpers.py index b587b80..5ca32ec 100644 --- a/pages/helpers.py +++ b/pages/helpers.py @@ -63,6 +63,20 @@ def get_extra(obj, attr_name, default=None): extra = obj.get('extra') return extra.get(attr_name, default) if extra else default + +def set_extra(obj, attr_name, attr_value): + prev_val = get_extra(obj, attr_name) + + if not obj.extra: + # In case we have a None type or empty string, initialize + # extra as a dict because if we're using this method the intention + # is to set it to some value. + obj.extra = {} + obj.extra.update({attr_name: attr_value}) + obj.extra = json.dumps(obj.extra) + return prev_val + + def update_context_urls(context, urls): if 'urls' in context: for key, val in six.iteritems(urls): diff --git a/pages/mixins.py b/pages/mixins.py index b621803..5f875fb 100644 --- a/pages/mixins.py +++ b/pages/mixins.py @@ -330,7 +330,7 @@ def progress(self): sequence=self.sequence, rank=self.rank) with transaction.atomic(): - self._progress, _ = EnumeratedProgress.objects.get_or_create( + self._progress, _ = EnumeratedProgress.objects.get_or_create( sequence_progress=self.sequence_progress, step=step) return self._progress diff --git a/pages/models.py b/pages/models.py index 80db861..ec30d69 100644 --- a/pages/models.py +++ b/pages/models.py @@ -457,7 +457,7 @@ class EnumeratedElements(models.Model): One element in a sequence """ sequence = models.ForeignKey(Sequence, on_delete=models.CASCADE, - related_name='sequence_enumerated_elements') + related_name='sequence_enumerated_elements') content = models.ForeignKey(PageElement, on_delete=models.CASCADE) rank = models.IntegerField( help_text=_("Used to order elements when presenting a sequence")) From df5f4e613650ca4267479e469389984569c6142f Mon Sep 17 00:00:00 2001 From: Noman Bukhari Date: Tue, 19 Mar 2024 05:37:25 -0700 Subject: [PATCH 8/8] Removes json.dumps to save obj.extra --- pages/helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pages/helpers.py b/pages/helpers.py index 5ca32ec..93353a9 100644 --- a/pages/helpers.py +++ b/pages/helpers.py @@ -73,7 +73,6 @@ def set_extra(obj, attr_name, attr_value): # is to set it to some value. obj.extra = {} obj.extra.update({attr_name: attr_value}) - obj.extra = json.dumps(obj.extra) return prev_val