diff options
Diffstat (limited to 'stats')
-rw-r--r-- | stats/__init__.py | 0 | ||||
-rw-r--r-- | stats/admin.py | 14 | ||||
-rw-r--r-- | stats/apps.py | 5 | ||||
-rw-r--r-- | stats/migrations/0001_initial.py | 42 | ||||
-rw-r--r-- | stats/migrations/0002_release_0_29_1.py | 23 | ||||
-rw-r--r-- | stats/migrations/__init__.py | 0 | ||||
-rw-r--r-- | stats/models.py | 140 | ||||
-rw-r--r-- | stats/templates/view_lesson_tracking.html | 140 | ||||
-rw-r--r-- | stats/test_models.py | 184 | ||||
-rw-r--r-- | stats/tests.py | 138 | ||||
-rw-r--r-- | stats/urls.py | 12 | ||||
-rw-r--r-- | stats/views.py | 70 |
12 files changed, 768 insertions, 0 deletions
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/migrations/0001_initial.py b/stats/migrations/0001_initial.py new file mode 100644 index 0000000..4bbae49 --- /dev/null +++ b/stats/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.7 on 2020-11-07 13:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('yaksh', '0027_release_0_28_0'), + ] + + operations = [ + migrations.CreateModel( + name='TrackLesson', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('current_time', models.CharField(default='00:00:00', max_length=100)), + ('video_duration', models.CharField(default='00:00:00', max_length=100)), + ('creation_time', models.DateTimeField(auto_now_add=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yaksh.Course')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yaksh.Lesson')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'course', 'lesson')}, + }, + ), + migrations.CreateModel( + name='LessonLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_access_time', models.DateTimeField(default=django.utils.timezone.now)), + ('track', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stats.TrackLesson')), + ], + ), + ] diff --git a/stats/migrations/0002_release_0_29_1.py b/stats/migrations/0002_release_0_29_1.py new file mode 100644 index 0000000..44f1a54 --- /dev/null +++ b/stats/migrations/0002_release_0_29_1.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-11-12 12:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stats', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='lessonlog', + name='current_time', + field=models.CharField(default='00:00:00', max_length=20), + ), + migrations.AddField( + model_name='tracklesson', + name='watched', + field=models.BooleanField(default=False), + ), + ] diff --git a/stats/migrations/__init__.py b/stats/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/stats/migrations/__init__.py diff --git a/stats/models.py b/stats/models.py new file mode 100644 index 0000000..84ac7ae --- /dev/null +++ b/stats/models.py @@ -0,0 +1,140 @@ +# Python Imports +import pandas as pd + +# Django Imports +from django.db import models +from django.utils import timezone +from django.contrib.auth.models import User +from django.db.models import F + +# Local Imports +from yaksh.models import Course, Lesson + + +def str_to_datetime(s): + return timezone.datetime.strptime(s, '%H:%M:%S') + + +def str_to_time(s): + return timezone.datetime.strptime(s, '%H:%M:%S').time() + + +def time_to_seconds(time): + return timezone.timedelta(hours=time.hour, minutes=time.minute, + seconds=time.second).total_seconds() + + +class TrackLessonManager(models.Manager): + def get_percentage_data(self, tracked_lessons): + percentage_data = {"1": 0, "2": 0, "3": 0, "4": 0} + for tracked in tracked_lessons: + percent = tracked.get_percentage_complete() + if percent < 25: + percentage_data["1"] = percentage_data["1"] + 1 + elif percent >= 25 and percent < 50: + percentage_data["2"] = percentage_data["2"] + 1 + elif percent >= 50 and percent < 75: + percentage_data["3"] = percentage_data["3"] + 1 + elif percent >= 75: + percentage_data["4"] = percentage_data["4"] + 1 + return percentage_data + + +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) + watched = models.BooleanField(default=False) + + objects = TrackLessonManager() + + class Meta: + unique_together = ('user', 'course', 'lesson') + + def get_log_counter(self): + return self.lessonlog_set.count() + + def get_current_time(self): + if self.current_time == '00:00:00': + return '00:00:00' + return self.current_time + + def get_video_duration(self): + if self.video_duration == '00:00:00': + return '00:00:00' + return self.video_duration + + def set_current_time(self, ct): + t = timezone.datetime.strptime(ct, '%H:%M:%S').time() + current = timezone.datetime.strptime(self.current_time, + '%H:%M:%S').time() + if t > current: + self.current_time = ct + + def get_percentage_complete(self): + ctime = self.current_time + vduration = self.video_duration + if ctime == '00:00:00' and vduration == '00:00:00': + return 0 + duration = str_to_time(vduration) + watch_time = str_to_time(ctime) + duration_seconds = time_to_seconds(duration) + watched_seconds = time_to_seconds(watch_time) + percentage = round((watched_seconds / duration_seconds) * 100) + return percentage + + def get_last_access_time(self): + lesson_logs = self.lessonlog_set + last_access_time = self.creation_time + if lesson_logs.exists(): + last_access_time = lesson_logs.last().last_access_time + return last_access_time + + def set_watched(self): + ctime = self.current_time + vduration = self.video_duration + if ctime != '00:00:00' and vduration != '00:00:00': + duration = str_to_time(vduration) + watch_time = (str_to_datetime(ctime) + timezone.timedelta( + seconds=120)).time() + self.watched = watch_time >= duration + + def get_watched(self): + if not self.watched: + self.set_watched() + self.save() + return self.watched + + def time_spent(self): + if self.video_duration != '00:00:00': + hits = self.get_log_counter() + duration = str_to_time(self.video_duration) + hit_duration = int((time_to_seconds(duration))/4) + total_duration = hits * hit_duration + return str(timezone.timedelta(seconds=total_duration)) + return self.get_current_time() + + def get_no_of_vists(self): + lesson_logs = self.lessonlog_set.values("last_access_time").annotate( + visits=F('last_access_time') + ) + df = pd.DataFrame(lesson_logs) + visits = 1 + if not df.empty: + visits = df.groupby( + [df['visits'].dt.date] + ).first().count()['visits'] + return visits + + 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) + current_time = models.CharField(max_length=20, default="00:00:00") + 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..b59fa7a --- /dev/null +++ b/stats/templates/view_lesson_tracking.html @@ -0,0 +1,140 @@ +{% extends "manage.html" %} +{% load static %} +{% block title %} Lesson Video Stats {% endblock %} +{% block script %} +<script type="text/javascript" src="{% static 'yaksh/js/jquery.tablesorter.min.js' %}"> +</script> +<script type="text/javascript"> + $(document).ready(function() { + $.tablesorter.addWidget({ + id: "numbering", + format: function(table) { + var c = table.config; + $("tr:visible", table.tBodies[0]).each(function(i) { + $(this).find('td').eq(0).text(i + 1); + }); + } + }); + $("#stats-table").tablesorter({ + headers: {0: { sorter: false }}, widgets: ['numbering'] + }); + }); +</script> +{% endblock %} +{% block content %} +<div class="container-fluid"> + {% 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:get_course_modules' course_id %}"> + <i class="fa fa-arrow-left"></i> Back + </a> + <br><br> + <div class="row"> + <div class="card" style="margin: 1%"> + <div class="col" id='barDiv1'></div> + </div> + <div class="card" style="margin: 1%"> + <div class="col" id="barDiv2"></div> + </div> + <div class="card" style="margin: 1%"> + <div class="col" id="barDiv3"></div> + </div> + </div> + <script type="text/javascript"> + var config = {responsive: true, displayModeBar: false} + var data = [ + { + x: ["Completed", "Not Completed"], + y: ["{{completion.0}}", "{{completion.1}}"], + type: 'bar' + } + ]; + var layout = { + title: "Number of completions (Out of {{visits.2}})", + xaxis: {title: 'Completion status'}, + yaxis: {title: 'Count'}, + width: 400, + height: 400, + }; + Plotly.newPlot('barDiv1', data, layout, config); + var data = [ + { + x: ["Visited", "Not Visited"], + y: ["{{visits.0}}", "{{visits.1}}"], + type: 'bar' + } + ]; + var layout = { + title: "Number of visits (Out of {{visits.2}})", + xaxis: {title: 'Visit status'}, + yaxis: {title: 'Count'}, + width: 400, + height: 400, + }; + Plotly.newPlot('barDiv2', data, layout, config); + var x_data = ["0-25", "25-50", "50-75", "75-100"], y_data = []; + {% for i, j in percentage_data.items %} + y_data.push("{{j}}") + {% endfor %} + var data = [{x: x_data, y: y_data, type: 'bar'}]; + var layout = { + title: "Range wise completion (Out of {{total}})", + xaxis: {title: 'Percentage Range'}, + yaxis: {title: 'Count'}, + width: 400, + height: 400, + }; + Plotly.newPlot('barDiv3', data, layout, config); + </script> + <br> + {% include "yaksh/paginator.html" %} + <br> + <div class="table-responsive"> + <table class="table table-dark table-responsive-sm" id="stats-table"> + <thead class="thead-dark"> + <tr> + <th>Sr No.</th> + <th>Student Name <i class="fa fa-sort"></i></th> + <th>Last access on <i class="fa fa-sort"></i></th> + <th>Started on <i class="fa fa-sort"></i></th> + <th>Current Duration <i class="fa fa-sort"></i></th> + <th>Video Duration <i class="fa fa-sort"></i></th> + <th>Percentage <i class="fa fa-sort"></i></th> + <th>Watched <i class="fa fa-sort"></i></th> + <th>Total Time Spent <i class="fa fa-sort"></i></th> + <th>Total Visits <i class="fa fa-sort"></i></th> + </tr> + </thead> + <tbody class="list"> + {% for track in trackings %} + <tr> + <td>{{ forloop.counter0 }}</td> + <td>{{track.user.get_full_name}}</td> + <td>{{track.get_last_access_time}}</td> + <td>{{track.creation_time}}</td> + <td>{{track.get_current_time}}</td> + <td>{{track.get_video_duration}}</td> + <td>{{track.get_percentage_complete}} %</td> + <td> + {% with track.get_watched as watched %} + {% if watched %} + <span class="badge-pill badge-success">{{watched}}</span> + {% else %} + <span class="badge-pill badge-warning">{{watched}}</span> + {% endif %} + {% endwith %} + </td> + <td>{{track.time_spent}}</td> + <td>{{track.get_no_of_vists}}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% endwith %} + <br> + {% include "yaksh/paginator.html" %} +</div> +{% endblock %} diff --git a/stats/test_models.py b/stats/test_models.py new file mode 100644 index 0000000..7f84330 --- /dev/null +++ b/stats/test_models.py @@ -0,0 +1,184 @@ +from django.test import TestCase +from django.contrib.auth.models import User +from django.utils import timezone +from stats.models import TrackLesson, LessonLog +from yaksh.models import Course, Lesson, LearningModule, LearningUnit + + +class TrackLessonTestCase(TestCase): + def setUp(self): + creator = User.objects.create(username='creator', password='test', + email='test1@test.com') + self.student = User.objects.create(username='student', password='test', + email='test2@test.com') + self.course = Course.objects.create( + name="Test Course", enrollment="Enroll Request", creator=creator + ) + learning_module = LearningModule.objects.create( + name='LM', description='module', creator=creator + ) + self.lesson = Lesson.objects.create( + name='Lesson', description='Video Lesson', creator=creator + ) + learning_unit = LearningUnit.objects.create(order=1, type='lesson', + lesson=self.lesson) + learning_module.learning_unit.add(learning_unit) + learning_module.save() + self.course.learning_module.add(learning_module) + self.course.students.add(self.student) + self.course.save() + self.tracker = TrackLesson.objects.create(user=self.student, + course=self.course, + lesson=self.lesson) + LessonLog.objects.create(track=self.tracker) + self.last_access_time = timezone.now() + LessonLog.objects.create(track=self.tracker, + last_access_time=self.last_access_time) + + def tearDown(self): + User.objects.all().delete() + Course.objects.all().delete() + Lesson.objects.all().delete() + LearningUnit.objects.all().delete() + LearningModule.objects.all().delete() + LessonLog.objects.all().delete() + TrackLesson.objects.all().delete() + + def test_track_lesson(self): + # Given + tracker = self.tracker + + # Then + self.assertEqual(tracker.user, self.student) + self.assertEqual(tracker.course, self.course) + self.assertEqual(tracker.lesson, self.lesson) + self.assertEqual(tracker.current_time, '00:00:00') + self.assertEqual(tracker.video_duration, '00:00:00') + self.assertFalse(tracker.watched) + + def test_log_counter(self): + # Given + tracker = self.tracker + expected_count = 2 + + # When + counts = tracker.get_log_counter() + + # Then + self.assertEqual(counts, expected_count) + + def test_get_current_time(self): + # Given + tracker = self.tracker + expected_time = '00:00:00' + + # When + current_time = tracker.get_current_time() + + # Then + self.assertEqual(current_time, expected_time) + + def test_get_video_duration(self): + # Given + tracker = self.tracker + expected_duration = '00:00:00' + + # When + duration = tracker.get_video_duration() + + # Then + self.assertEqual(duration, expected_duration) + + def test_set_current_time(self): + # Given + tracker = self.tracker + ctime = timezone.now() + + # When + tracker.set_current_time(ctime.strftime('%H:%M:%S')) + tracker.save() + updated_time = tracker.get_current_time() + + # Then + self.assertEqual(updated_time, ctime.strftime('%H:%M:%S')) + + # Given + time_now = timezone.now() + invalid_ctime = ctime - timezone.timedelta(seconds=100) + + # When + tracker.set_current_time(invalid_ctime.strftime('%H:%M:%S')) + tracker.save() + old_time = tracker.get_current_time() + + # Then + self.assertEqual(old_time, ctime.strftime('%H:%M:%S')) + + def test_get_percentage_complete(self): + # Given + tracker = self.tracker + expected_percentage = 0 + + # When + percentage = tracker.get_percentage_complete() + + # Then + self.assertEqual(percentage, expected_percentage) + + # Given + expected_percentage = 75 + + # When + tracker.set_current_time('00:03:00') + tracker.video_duration = '00:04:00' + tracker.save() + percentage = tracker.get_percentage_complete() + + # Then + self.assertEqual(percentage, expected_percentage) + + def test_get_last_access_time(self): + # Given + tracker = self.tracker + expected_time = self.last_access_time + + # When + time = tracker.get_last_access_time() + + # Then + self.assertEqual(time, expected_time) + + def test_set_get_watched(self): + # Given + tracker = self.tracker + + # When + tracker.set_watched() + + # Then + self.assertFalse(tracker.get_watched()) + + # Given + tracker = self.tracker + + # When + tracker.set_current_time('00:03:55') + tracker.video_duration = '00:04:00' + tracker.save() + tracker.set_watched() + + # Then + self.assertTrue(tracker.get_watched()) + + def test_time_spent(self): + # Given + tracker = self.tracker + expected_time = '00:02:00' + + # When + tracker.video_duration = '00:04:00' + tracker.save() + time = tracker.time_spent() + + # Then + self.assertTrue(expected_time, time) diff --git a/stats/tests.py b/stats/tests.py new file mode 100644 index 0000000..540ff4d --- /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)) + obtained_tracker = list(response_data.get( + 'objects').object_list) + 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..a5cdeb7 --- /dev/null +++ b/stats/views.py @@ -0,0 +1,70 @@ +# 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, str_to_time, time_to_seconds +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.set_current_time(current_time) + track.video_duration = video_duration + track.save() + if not track.watched: + track.set_watched() + track.save() + LessonLog.objects.create( + track_id=track.id, current_time=current_time, + last_access_time=timezone.now() + ) + 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.objects.prefetch_related("students"), id=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") + percentage_data = TrackLesson.objects.get_percentage_data(trackings) + visited = trackings.count() + completed = trackings.filter(watched=True).count() + students_total = course.students.count() + paginator = Paginator(trackings, 30) + page = request.GET.get('page') + trackings = paginator.get_page(page) + context = { + 'objects': trackings, 'total': visited, 'course_id': course_id, + 'lesson_id': lesson_id, "percentage_data": percentage_data, + 'completion': [completed, students_total-completed, students_total], + 'visits': [visited, students_total-visited, students_total] + } + return render(request, 'view_lesson_tracking.html', context) |