From 5936383ed3ca28ca3c2ed280ca8f75d9ccb98298 Mon Sep 17 00:00:00 2001 From: adityacp Date: Fri, 6 Nov 2020 18:19:56 +0530 Subject: Initial video tracking --- .sampleenv | 13 ------ online_test/settings.py | 1 + online_test/urls.py | 1 + yaksh/static/yaksh/js/show_toc.js | 57 +++++++++++++++++++---- yaksh/templates/yaksh/show_lesson_statistics.html | 17 +++++-- yaksh/templates/yaksh/show_video.html | 7 ++- yaksh/views.py | 6 ++- 7 files changed, 75 insertions(+), 27 deletions(-) delete mode 100644 .sampleenv diff --git a/.sampleenv b/.sampleenv deleted file mode 100644 index a31ec1f..0000000 --- a/.sampleenv +++ /dev/null @@ -1,13 +0,0 @@ -# Django settings -SECRET_KEY=dUmMy_s3cR3t_k3y -#DB_ENGINE=mysql -#DB_NAME=yaksh -#DB_USER=root -#DB_PASSWORD=root -#DB_HOST=yaksh-db -#DB_PORT=3306 -# Yaksh settings -N_CODE_SERVERS=5 -#SERVER_POOL_PORT=53579 -#SERVER_HOST_NAME=http://yaksh-codeserver -#SERVER_TIMEOUT=4 diff --git a/online_test/settings.py b/online_test/settings.py index 11ab0ef..e7e19a0 100644 --- a/online_test/settings.py +++ b/online_test/settings.py @@ -45,6 +45,7 @@ INSTALLED_APPS = ( 'taggit', 'social_django', 'grades', + 'stats', 'django_celery_beat', 'django_celery_results', 'notifications_plugin', diff --git a/online_test/urls.py b/online_test/urls.py index bb5a04a..2a53d97 100644 --- a/online_test/urls.py +++ b/online_test/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ url(r'^', include('social_django.urls', namespace='social')), url(r'^grades/', include(('grades.urls', 'grades'))), url(r'^api/', include('api.urls', namespace='api')), + url(r'^stats/', include('stats.urls', namespace='stats')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/yaksh/static/yaksh/js/show_toc.js b/yaksh/static/yaksh/js/show_toc.js index a2507d0..55e9236 100644 --- a/yaksh/static/yaksh/js/show_toc.js +++ b/yaksh/static/yaksh/js/show_toc.js @@ -24,23 +24,49 @@ $(document).ready(function() { var totalSeconds; store_video_time(contents_by_time); var time_arr_length = video_time.length; + var total_duration; + player.on('ready', event => { + total_duration = parseInt(player.duration); + start_tracker((total_duration * 1000) / 4, player); + }); + player.on('timeupdate', event => { - if (time_arr_length > 0 && player.currentTime >= video_time[loc]) { + var current_time = player.currentTime; + $("#video_duration").val(get_time_in_hrs(total_duration)); + $("#current_video_time").val(get_time_in_hrs(current_time)); + if (time_arr_length > 0 && current_time >= video_time[loc]) { var content = contents_by_time[loc]; loc += 1; if(content.content == 1) { show_topic($("#toc_desc_"+content.id).val(), false); } else { - player.pause(); if(player.fullscreen.active) player.fullscreen.exit(); url = $("#toc_"+content.id).val(); - ajax_call(url, "GET"); + ajax_call(url, "GET", screen_lock=true); } } }); + player.on('ended', event => { + var csrf = document.getElementById("track-form").elements[0].value; + ajax_call($("#track-form").attr("action"), $("#track-form").attr("method"), + $("#track-form").serialize(), csrf, screen_lock=false); + window.location.href = $("#next_unit").attr("href"); + }); }); + +function start_tracker(slice_duration, player) { + setTimeout(function run() { + if(player && player.playing) { + var csrf = document.getElementById("track-form").elements[0].value; + ajax_call($("#track-form").attr("action"), $("#track-form").attr("method"), + $("#track-form").serialize(), csrf, screen_lock=false); + } + setTimeout(run, slice_duration); + }, slice_duration); +} + function show_topic(description, override) { var topic_div = $("#topic-description"); if(override) { @@ -51,8 +77,10 @@ function show_topic(description, override) { } function store_video_time(contents) { - for (var j = 0; j < contents.length; j++) - video_time.push(get_time_in_seconds(contents[j].time)); + if(contents) { + for (var j = 0; j < contents.length; j++) + video_time.push(get_time_in_seconds(contents[j].time)); + } } function get_time_in_seconds(time) { @@ -63,6 +91,18 @@ function get_time_in_seconds(time) { return hh * 3600 + mm * 60 + ss; } +function get_time_in_hrs(time) { + totalSeconds = parseInt(time) + hours = Math.floor(totalSeconds / 3600); + totalSeconds %= 3600; + minutes = Math.floor(totalSeconds / 60); + seconds = totalSeconds % 60; + hours = hours < 10 ? "0" + hours : hours; + minutes = minutes < 10 ? "0" + minutes : minutes; + seconds = seconds < 10 ? "0" + seconds : seconds; + return hours + ":" + minutes + ":" + seconds; +} + function lock_screen() { document.getElementById("loader").style.display = "block"; if ($("#check").is(":visible")) { @@ -87,7 +127,8 @@ function show_question(data) { e.preventDefault(); lock_screen(); var csrf = document.getElementById("submit-quiz-form").elements[0].value; - ajax_call($(this).attr("action"), $(this).attr("method"), $(this).serialize(), csrf); + ajax_call($(this).attr("action"), $(this).attr("method"), + $(this).serialize(), csrf, screen_lock=true); }); } @@ -140,8 +181,8 @@ function show_message(message, msg_type) { } } -function ajax_call(url, method, data, csrf) { - lock_screen(); +function ajax_call(url, method, data, csrf, screen_lock=true) { + if(screen_lock) {lock_screen();} $.ajax({ url: url, timeout: 15000, diff --git a/yaksh/templates/yaksh/show_lesson_statistics.html b/yaksh/templates/yaksh/show_lesson_statistics.html index 31261f3..0c35e40 100644 --- a/yaksh/templates/yaksh/show_lesson_statistics.html +++ b/yaksh/templates/yaksh/show_lesson_statistics.html @@ -5,10 +5,19 @@ {% block content %}

- -  Back - -

+
+ + +
+
{% if data %}
diff --git a/yaksh/templates/yaksh/show_video.html b/yaksh/templates/yaksh/show_video.html index d27293e..d6d08ea 100644 --- a/yaksh/templates/yaksh/show_video.html +++ b/yaksh/templates/yaksh/show_video.html @@ -196,6 +196,11 @@ {% endif %} {% endif %}
+
+ {% csrf_token %} + + +

Lesson Description

@@ -226,7 +231,7 @@

- + Next  {% endif %} diff --git a/yaksh/views.py b/yaksh/views.py index dd9090d..c6bd560 100644 --- a/yaksh/views.py +++ b/yaksh/views.py @@ -42,6 +42,7 @@ from yaksh.models import ( LearningUnit, LearningModule, CourseStatus, question_types, Post, Comment, Topic, TableOfContents, LessonQuizAnswer, MicroManager ) +from stats.models import TrackLesson from yaksh.forms import ( UserRegisterForm, UserLoginForm, QuizForm, QuestionForm, QuestionFilterForm, CourseForm, ProfileForm, @@ -2817,6 +2818,9 @@ def show_lesson(request, lesson_id, module_id, course_id): if not learn_unit.is_prerequisite_complete(user, learn_module, course): msg = "You have not completed previous Lesson/Quiz/Exercise" return view_module(request, learn_module.id, course_id, msg=msg) + track, created = TrackLesson.objects.get_or_create( + user_id=user.id, course_id=course_id, lesson_id=lesson_id + ) lesson_ct = ContentType.objects.get_for_model(learn_unit.lesson) title = learn_unit.lesson.name @@ -2849,7 +2853,7 @@ def show_lesson(request, lesson_id, module_id, course_id): 'course': course, 'state': "lesson", "all_modules": all_modules, 'learning_units': learning_units, "current_unit": learn_unit, 'learning_module': learn_module, 'toc': toc, - 'contents_by_time': contents_by_time, + 'contents_by_time': contents_by_time, 'track_id': track.id, 'comments': comments, 'form': form, 'post': post} return my_render_to_response(request, 'yaksh/show_video.html', context) -- cgit From fe5b3c41aa898fa7491a7ec9bce28c5e1c5b542d Mon Sep 17 00:00:00 2001 From: adityacp Date: Fri, 6 Nov 2020 18:21:48 +0530 Subject: Statistics app for video tracking --- stats/__init__.py | 0 stats/admin.py | 8 ++++ stats/apps.py | 5 +++ stats/models.py | 24 +++++++++++ stats/templates/view_lesson_tracking.html | 69 +++++++++++++++++++++++++++++++ stats/tests.py | 3 ++ stats/urls.py | 12 ++++++ stats/views.py | 54 ++++++++++++++++++++++++ 8 files changed, 175 insertions(+) create mode 100644 stats/__init__.py create mode 100644 stats/admin.py create mode 100644 stats/apps.py create mode 100644 stats/models.py create mode 100644 stats/templates/view_lesson_tracking.html create mode 100644 stats/tests.py create mode 100644 stats/urls.py create mode 100644 stats/views.py diff --git a/stats/__init__.py b/stats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stats/admin.py b/stats/admin.py new file mode 100644 index 0000000..b400d27 --- /dev/null +++ b/stats/admin.py @@ -0,0 +1,8 @@ +# Django Imports +from django.contrib import admin + +# Local Imports +from stats.models import TrackLesson + + +admin.site.register(TrackLesson) diff --git a/stats/apps.py b/stats/apps.py new file mode 100644 index 0000000..2d09b92 --- /dev/null +++ b/stats/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class StatsConfig(AppConfig): + name = 'stats' diff --git a/stats/models.py b/stats/models.py new file mode 100644 index 0000000..f2f1bce --- /dev/null +++ b/stats/models.py @@ -0,0 +1,24 @@ +# Django Imports +from django.db import models +from django.utils import timezone +from django.contrib.auth.models import User + +# Local Imports +from yaksh.models import Course, Lesson + + +class TrackLesson(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + course = models.ForeignKey(Course, on_delete=models.CASCADE) + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE) + current_time = models.CharField(max_length=100, default="00:00:00") + video_duration = models.CharField(max_length=100, default="00:00:00") + last_access_time = models.DateTimeField(default=timezone.now) + creation_time = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'course', 'lesson') + + def __str__(self): + return (f"Track {self.lesson} in {self.course} " + f"for {self.user.get_full_name()}") diff --git a/stats/templates/view_lesson_tracking.html b/stats/templates/view_lesson_tracking.html new file mode 100644 index 0000000..fd87d70 --- /dev/null +++ b/stats/templates/view_lesson_tracking.html @@ -0,0 +1,69 @@ +{% extends "manage.html" %} + +{% block title %} Lesson Views {% endblock %} +{% block script %} + +{% endblock %} +{% block content %} +
+ {% with objects.object_list as trackings %} +
+

Statistics for {% with trackings|first as entry %} {{entry.lesson}} {% endwith %}

+
+ +  Back + +

+ {% include "yaksh/paginator.html" %} +
+

{{total}} student(s) viewed this lesson

+ + + + + + + + + + + {% for track in trackings %} + + + + + + + + + + {% endfor %} +
Sr No.Student NameLast access onStarted onCurrent TimeVideo DurationPercentage watched
{{ forloop.counter0|add:objects.start_index }}{{track.user.get_full_name}}{{track.last_access_time}}{{track.creation_time}}{{track.current_time}}{{track.video_duration}} + +
+ {% endwith %} +
+ {% include "yaksh/paginator.html" %} +
+{% endblock %} \ No newline at end of file diff --git a/stats/tests.py b/stats/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/stats/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/stats/urls.py b/stats/urls.py new file mode 100644 index 0000000..f11148f --- /dev/null +++ b/stats/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from stats import views + + +app_name = "stats" + +urlpatterns = [ + path('submit/video/watch/', + views.add_tracker, name='add_tracker'), + path('view/watch/stats//', + views.view_lesson_watch_stats, name='view_lesson_watch_stats'), +] diff --git a/stats/views.py b/stats/views.py new file mode 100644 index 0000000..ddbc1b3 --- /dev/null +++ b/stats/views.py @@ -0,0 +1,54 @@ +# Django Imports +from django.shortcuts import render, get_object_or_404 +from django.http import JsonResponse +from django.utils import timezone +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator + +# Local Imports +from stats.models import TrackLesson +from yaksh.models import Course +from yaksh.decorators import email_verified + + +@login_required +@email_verified +def add_tracker(request, tracker_id): + user = request.user + track = get_object_or_404( + TrackLesson.objects.select_related("course"), id=tracker_id + ) + if not track.course.is_student(user): + raise Http404("You are not enrolled in this course") + context = {} + video_duration = request.POST.get('video_duration') + current_time = request.POST.get('current_video_time') + if current_time: + track.current_time = current_time + track.video_duration = video_duration + track.last_access_time = timezone.now() + track.save() + success = True + else: + success = False + context = {"success": success} + return JsonResponse(context) + + +@login_required +@email_verified +def view_lesson_watch_stats(request, course_id, lesson_id): + user = request.user + course = get_object_or_404(Course, pk=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + raise Http404('This course does not belong to you') + trackings = TrackLesson.objects.get_queryset().filter( + course_id=course_id, lesson_id=lesson_id + ).order_by("id") + total = trackings.count() + paginator = Paginator(trackings, 30) + page = request.GET.get('page') + trackings = paginator.get_page(page) + context = {'objects': trackings, 'total': total, 'course_id': course_id, + 'lesson_id': lesson_id} + return render(request, 'view_lesson_tracking.html', context) -- cgit From 6b5b21fc26879c1724bf02952584196f6c302b91 Mon Sep 17 00:00:00 2001 From: adityacp Date: Sat, 7 Nov 2020 11:58:22 +0530 Subject: Add tests for lesson tracking --- .sampleenv | 13 ++++++ .travis.yml | 1 + stats/tests.py | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- stats/views.py | 1 + 4 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 .sampleenv diff --git a/.sampleenv b/.sampleenv new file mode 100644 index 0000000..a31ec1f --- /dev/null +++ b/.sampleenv @@ -0,0 +1,13 @@ +# Django settings +SECRET_KEY=dUmMy_s3cR3t_k3y +#DB_ENGINE=mysql +#DB_NAME=yaksh +#DB_USER=root +#DB_PASSWORD=root +#DB_HOST=yaksh-db +#DB_PORT=3306 +# Yaksh settings +N_CODE_SERVERS=5 +#SERVER_POOL_PORT=53579 +#SERVER_HOST_NAME=http://yaksh-codeserver +#SERVER_TIMEOUT=4 diff --git a/.travis.yml b/.travis.yml index fd0746c..d7e63e8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,6 +29,7 @@ before_script: script: - coverage erase - coverage run -p manage.py test -v 2 yaksh + - coverage run -p manage.py test -v 2 stats - coverage run -p manage.py test -v 2 grades - coverage run -p manage.py test -v 2 yaksh.live_server_tests.load_test - coverage run -p manage.py test -v 2 api diff --git a/stats/tests.py b/stats/tests.py index 7ce503c..c256feb 100644 --- a/stats/tests.py +++ b/stats/tests.py @@ -1,3 +1,138 @@ -from django.test import TestCase +# Python Imports +import json + +# Django Imports +from django.test import TestCase, Client +from django.contrib.auth.models import User, Group +from django.urls import reverse + +# Local Imports +from stats.models import TrackLesson +from yaksh.models import Course, Lesson, LearningUnit, LearningModule + + +class TestTrackLesson(TestCase): + def setUp(self): + self.client = Client() + self.mod_group, created = Group.objects.get_or_create(name='moderator') + self.user_plaintext_pass = 'demo' + self.user = User.objects.create_user( + username='demo_user', + password=self.user_plaintext_pass, + first_name='first_name', + last_name='last_name', + email='demo@test.com', + ) + + # Create Student + self.student_plaintext_pass = 'demo_student' + self.student = User.objects.create_user( + username='demo_student', + password=self.student_plaintext_pass, + first_name='student_first_name', + last_name='student_last_name', + email='demo_student@test.com' + ) + + # Add to moderator group + self.mod_group.user_set.add(self.user) + + self.course = Course.objects.create( + name="Test_course", + enrollment="Open Enrollment", creator=self.user + ) + self.lesson = Lesson.objects.create( + name="Test_lesson", description="test description", + creator=self.user) + self.learning_unit = LearningUnit.objects.create( + order=0, type="lesson", lesson=self.lesson + ) + self.learning_module = LearningModule.objects.create( + order=0, name="Test_module", description="Demo module", + check_prerequisite=False, creator=self.user + ) + self.learning_module.learning_unit.add(self.learning_unit.id) + self.track = TrackLesson.objects.create( + user_id=self.student.id, course_id=self.course.id, + lesson_id=self.lesson.id + ) + + def tearDown(self): + self.client.logout() + self.mod_group.delete() + self.user.delete() + self.student.delete() + self.course.delete() + self.learning_unit.delete() + self.learning_module.delete() + + def test_add_video_track(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + + # Student not enrolled in the course fails to add the tracking + response = self.client.post( + reverse('stats:add_tracker', + kwargs={"tracker_id": self.track.id}), + data={'video_duration': '00:05:00'} + ) + self.assertEqual(response.status_code, 404) + + self.course.students.add(self.student.id) + # No current time given in the post data + response = self.client.post( + reverse('stats:add_tracker', + kwargs={"tracker_id": self.track.id}), + data={'video_duration': '00:05:00'} + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json().get('success')) + + # Valid post data + response = self.client.post( + reverse('stats:add_tracker', + kwargs={"tracker_id": self.track.id}), + data={'video_duration': '00:05:00', + 'current_video_time': '00:01:00'} + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json().get('success')) + + def test_disallow_student_view_tracking(self): + self.client.login( + username=self.student.username, + password=self.student_plaintext_pass + ) + + # Fails to view the lesson data for student + response = self.client.get( + reverse('stats:view_lesson_watch_stats', + kwargs={"course_id": self.course.id, + "lesson_id": self.lesson.id}) + ) + self.assertEqual(response.status_code, 404) + + def test_allow_moderator_view_tracking(self): + self.client.login( + username=self.user.username, + password=self.user_plaintext_pass + ) + # Course creator can view the lesson data + response = self.client.get( + reverse('stats:view_lesson_watch_stats', + kwargs={"course_id": self.course.id, + "lesson_id": self.lesson.id}) + ) + response_data = response.context + self.assertEqual(response.status_code, 200) + self.assertEqual(response_data.get('total'), 1) + expected_tracker = list(TrackLesson.objects.filter( + user_id=self.student.id, course_id=self.course.id, + lesson_id=self.lesson.id).values_list("id", flat=True)) + obtained_tracker = list(response_data.get( + 'objects').object_list.values_list("id", flat=True)) + self.assertEqual(obtained_tracker, expected_tracker) -# Create your tests here. diff --git a/stats/views.py b/stats/views.py index ddbc1b3..3bfe9c3 100644 --- a/stats/views.py +++ b/stats/views.py @@ -4,6 +4,7 @@ from django.http import JsonResponse from django.utils import timezone from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator +from django.http import Http404 # Local Imports from stats.models import TrackLesson -- cgit From 7419b67b1928f30824a332d36afcdfddebaf2479 Mon Sep 17 00:00:00 2001 From: adityacp Date: Sat, 7 Nov 2020 12:09:22 +0530 Subject: Add table sorter --- stats/templates/view_lesson_tracking.html | 34 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/stats/templates/view_lesson_tracking.html b/stats/templates/view_lesson_tracking.html index fd87d70..fa891e3 100644 --- a/stats/templates/view_lesson_tracking.html +++ b/stats/templates/view_lesson_tracking.html @@ -1,7 +1,9 @@ {% extends "manage.html" %} - +{% load static %} {% block title %} Lesson Views {% endblock %} {% block script %} +