summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.travis.yml2
-rw-r--r--online_test/settings.py1
-rw-r--r--online_test/urls.py1
-rw-r--r--stats/__init__.py0
-rw-r--r--stats/admin.py14
-rw-r--r--stats/apps.py5
-rw-r--r--stats/models.py35
-rw-r--r--stats/templates/view_lesson_tracking.html75
-rw-r--r--stats/tests.py138
-rw-r--r--stats/urls.py12
-rw-r--r--stats/views.py57
-rw-r--r--yaksh/static/yaksh/js/show_toc.js57
-rw-r--r--yaksh/templates/yaksh/show_lesson_statistics.html17
-rw-r--r--yaksh/templates/yaksh/show_video.html7
-rw-r--r--yaksh/views.py6
15 files changed, 413 insertions, 14 deletions
diff --git a/.travis.yml b/.travis.yml
index fd0746c..0fad559 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -21,6 +21,7 @@ install:
before_script:
- python manage.py makemigrations notifications_plugin
+ - python manage.py makemigrations stats
- python manage.py makemigrations
- python manage.py migrate auth
- python manage.py migrate
@@ -29,6 +30,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/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/stats/__init__.py b/stats/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/stats/__init__.py
diff --git a/stats/admin.py b/stats/admin.py
new file mode 100644
index 0000000..16ca528
--- /dev/null
+++ b/stats/admin.py
@@ -0,0 +1,14 @@
+# Django Imports
+from django.contrib import admin
+
+# Local Imports
+from stats.models import TrackLesson
+
+
+class TrackLessonAdmin(admin.ModelAdmin):
+ search_fields = ['user__first_name', 'user__last_name', 'user__username',
+ 'course__name', 'lesson__name']
+ readonly_fields = ["course", "user", "lesson"]
+
+
+admin.site.register(TrackLesson, TrackLessonAdmin)
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..95def40
--- /dev/null
+++ b/stats/models.py
@@ -0,0 +1,35 @@
+# 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")
+ creation_time = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ unique_together = ('user', 'course', 'lesson')
+
+ def get_last_access_time_and_vists(self):
+ lesson_logs = self.lessonlog_set
+ last_access_time = None
+ if lesson_logs.exists():
+ last_access_time = lesson_logs.last().last_access_time
+ return last_access_time, lesson_logs.count()
+
+ def __str__(self):
+ return (f"Track {self.lesson} in {self.course} "
+ f"for {self.user.get_full_name()}")
+
+
+class LessonLog(models.Model):
+ track = models.ForeignKey(TrackLesson, on_delete=models.CASCADE)
+ last_access_time = models.DateTimeField(default=timezone.now)
diff --git a/stats/templates/view_lesson_tracking.html b/stats/templates/view_lesson_tracking.html
new file mode 100644
index 0000000..7962410
--- /dev/null
+++ b/stats/templates/view_lesson_tracking.html
@@ -0,0 +1,75 @@
+{% extends "manage.html" %}
+{% load static %}
+{% block title %} Lesson Views {% endblock %}
+{% block script %}
+<script type="text/javascript" src="{% static 'yaksh/js/jquery.tablesorter.min.js' %}">
+</script>
+<script type="text/javascript">
+ function get_time_in_seconds(time) {
+ var time = time.split(":");
+ var hh = parseInt(time[0]);
+ var mm = parseInt(time[1]);
+ var ss = parseInt(time[2]);
+ return hh * 3600 + mm * 60 + ss;
+ }
+
+ $(document).ready(function() {
+ $("#stats-table").tablesorter({});
+ $('#stats-table tr').each(function() {
+ var td = $(this).find("td");
+ var elapsed = td.eq(4).html();
+ var duration = td.eq(5).html();
+ if (elapsed != undefined || duration != undefined) {
+ percent = (get_time_in_seconds(elapsed) / get_time_in_seconds(duration)) * 100;
+ td.eq(6).html(Math.round(percent));
+ }
+ });
+ });
+</script>
+{% endblock %}
+{% block content %}
+<div class="container">
+ {% with objects.object_list as trackings %}
+ <center>
+ <h3>Statistics for {% with trackings|first as entry %} {{entry.lesson}} {% endwith %}</h3>
+ </center>
+ <a class="btn btn-primary" href="{% url 'yaksh:lesson_statistics' course_id lesson_id %}">
+ <i class="fa fa-arrow-left"></i>&nbsp;Back
+ </a>
+ <br><br>
+ {% include "yaksh/paginator.html" %}
+ <br>
+ <h4><strong>{{total}} student(s) viewed this lesson</strong></h4>
+ <table class="table table-responsive" id="stats-table">
+ <thead>
+ <tr>
+ <th>Sr No.</th>
+ <th>Student Name&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Last access on&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Started on&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Current Duration&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Video Duration&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Percentage watched&nbsp;<i class="fa fa-sort"></i></th>
+ <th>Total visits&nbsp;<i class="fa fa-sort"></i></th>
+ </tr>
+ </thead>
+ {% for track in trackings %}
+ <tr>
+ <td>{{ forloop.counter0|add:objects.start_index }}</td>
+ <td>{{track.user.get_full_name}}</td>
+ {% with track.get_last_access_time_and_vists as time_and_visits %}
+ <td>{{time_and_visits.0}}</td>
+ <td>{{track.creation_time}}</td>
+ <td>{{track.current_time}}</td>
+ <td>{{track.video_duration}}</td>
+ <td></td>
+ <td>{{time_and_visits.1}}</td>
+ {% endwith %}
+ </tr>
+ {% endfor %}
+ </table>
+ {% endwith %}
+ <br>
+ {% include "yaksh/paginator.html" %}
+</div>
+{% endblock %} \ No newline at end of file
diff --git a/stats/tests.py b/stats/tests.py
new file mode 100644
index 0000000..c256feb
--- /dev/null
+++ b/stats/tests.py
@@ -0,0 +1,138 @@
+# 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)
+
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/<int:tracker_id>',
+ views.add_tracker, name='add_tracker'),
+ path('view/watch/stats/<int:course_id>/<int:lesson_id>',
+ 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..f7c028f
--- /dev/null
+++ b/stats/views.py
@@ -0,0 +1,57 @@
+# 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
+from django.http import Http404
+
+# Local Imports
+from stats.models import TrackLesson, LessonLog
+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
+ LessonLog.objects.create(
+ track_id=track.id, 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)
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 %}
<div class="container-fluid">
<br>
- <a class="btn btn-primary" href="{% url 'yaksh:get_course_modules' course_id %}">
- <i class="fa fa-arrow-left"></i>&nbsp;Back
- </a>
- <br><br>
+ <div class="row">
+ <div class="col-md-2">
+ <a class="btn btn-primary" href="{% url 'yaksh:get_course_modules' course_id %}">
+ <i class="fa fa-arrow-left"></i>&nbsp;Back
+ </a>
+ </div>
+ <div class="col-md-4">
+ <a class="btn btn-outline-dark" href="{% url 'stats:view_lesson_watch_stats' course_id lesson.id %}">
+ <i class="fa fa-line-chart"></i>&nbsp;Video Statistics
+ </a>
+ </div>
+ </div>
+ <br>
{% if data %}
<div class="row">
<div class="col-md-4">
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 %}
<div class="col-md-8">
+ <form action="{% url 'stats:add_tracker' track_id %}" method="POST" id="track-form">
+ {% csrf_token %}
+ <input type="hidden" name="video_duration" id="video_duration">
+ <input type="hidden" name="current_video_time" id="current_video_time">
+ </form>
<div class="card">
<div class="card-header"><h3><strong>Lesson Description</strong></h3></div>
<div class="card-body">
@@ -226,7 +231,7 @@
</div>
</div>
<br>
- <a href="{% url 'yaksh:next_unit' course.id learning_module.id current_unit.id %}" class="btn btn-info btn-lg" >
+ <a href="{% url 'yaksh:next_unit' course.id learning_module.id current_unit.id %}" class="btn btn-info btn-lg" id="next_unit">
Next&nbsp;<i class="fa fa-step-forward"></i>
</a>
{% endif %}
diff --git a/yaksh/views.py b/yaksh/views.py
index b3b1e02..95a7218 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)