Saving & restoring video watching progress
Video progress updates: - Mark as 'done' when 90% or more is watched. - Keep 'done' flag when re-watching. The video progress is stored on three events, whichever comes first: - Every 30 seconds of video. - Every 10% of the video. - Every pause/stop/navigation to another page. - When we detect the video is looping.
This commit is contained in:
parent
3f8e0396cf
commit
2698be3e12
@ -123,6 +123,35 @@ users_schema = {
|
||||
'allow_unknown': True,
|
||||
},
|
||||
|
||||
# Node-specific information for this user.
|
||||
'nodes': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
# Per watched video info about where the user left off, both in time and in percent.
|
||||
'view_progress': {
|
||||
'type': 'dict',
|
||||
# Keyed by Node ID of the video asset. MongoDB doesn't support using
|
||||
# ObjectIds as key, so we cast them to string instead.
|
||||
'keyschema': {'type': 'string'},
|
||||
'valueschema': {
|
||||
'type': 'dict',
|
||||
'schema': {
|
||||
'progress_in_sec': {'type': 'float', 'min': 0},
|
||||
'progress_in_percent': {'type': 'integer', 'min': 0, 'max': 100},
|
||||
|
||||
# When the progress was last updated, so we can limit this history to
|
||||
# the last-watched N videos if we want, or show stuff in chrono order.
|
||||
'last_watched': {'type': 'datetime'},
|
||||
|
||||
# True means progress_in_percent = 100, for easy querying
|
||||
'done': {'type': 'boolean', 'default': False},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
|
||||
# Properties defined by extensions. Extensions should use their name (see the
|
||||
# PillarExtension.name property) as the key, and are free to use whatever they want as value,
|
||||
# but we suggest a dict for future extendability.
|
||||
|
@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
from eve.methods.get import get
|
||||
from flask import Blueprint
|
||||
from flask import Blueprint, request
|
||||
import werkzeug.exceptions as wz_exceptions
|
||||
|
||||
from pillar.api.utils import jsonify
|
||||
from pillar import current_app
|
||||
from pillar.api import utils
|
||||
from pillar.api.utils.authorization import require_login
|
||||
from pillar.auth import current_user
|
||||
|
||||
@ -15,7 +17,128 @@ blueprint_api = Blueprint('users_api', __name__)
|
||||
@require_login()
|
||||
def my_info():
|
||||
eve_resp, _, _, status, _ = get('users', {'_id': current_user.user_id})
|
||||
resp = jsonify(eve_resp['_items'][0], status=status)
|
||||
resp = utils.jsonify(eve_resp['_items'][0], status=status)
|
||||
return resp
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress')
|
||||
@require_login()
|
||||
def get_video_progress(video_id: str):
|
||||
"""Return video progress information.
|
||||
|
||||
Either a `204 No Content` is returned (no information stored),
|
||||
or a `200 Ok` with JSON from Eve's 'users' schema, from the key
|
||||
video.view_progress.<video_id>.
|
||||
"""
|
||||
|
||||
# Validation of the video ID; raises a BadRequest when it's not an ObjectID.
|
||||
# This isn't strictly necessary, but it makes this function behave symmetrical
|
||||
# to the set_video_progress() function.
|
||||
utils.str2id(video_id)
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
user_doc = users_coll.find_one(current_user.user_id, projection={'nodes.view_progress': True})
|
||||
try:
|
||||
progress = user_doc['nodes']['view_progress'][video_id]
|
||||
except KeyError:
|
||||
return '', 204
|
||||
if not progress:
|
||||
return '', 204
|
||||
|
||||
return utils.jsonify(progress)
|
||||
|
||||
|
||||
@blueprint_api.route('/video/<video_id>/progress', methods=['POST'])
|
||||
@require_login()
|
||||
def set_video_progress(video_id: str):
|
||||
"""Save progress information about a certain video.
|
||||
|
||||
Expected parameters:
|
||||
- progress_in_sec: float number of seconds
|
||||
- progress_in_perc: integer percentage of video watched (interval [0-100])
|
||||
"""
|
||||
my_log = log.getChild('set_video_progress')
|
||||
my_log.debug('Setting video progress for user %r video %r', current_user.user_id, video_id)
|
||||
|
||||
# Constructing this response requires an active app, and thus can't be done on module load.
|
||||
no_video_response = utils.jsonify({'_message': 'No such video'}, status=404)
|
||||
|
||||
try:
|
||||
progress_in_sec = float(request.form['progress_in_sec'])
|
||||
progress_in_perc = int(request.form['progress_in_perc'])
|
||||
except KeyError as ex:
|
||||
my_log.debug('Missing POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'missing a form field')
|
||||
except ValueError as ex:
|
||||
my_log.debug('Invalid value for POST field in request: %s', ex)
|
||||
raise wz_exceptions.BadRequest(f'Invalid value for field: {ex}')
|
||||
|
||||
users_coll = current_app.db('users')
|
||||
nodes_coll = current_app.db('nodes')
|
||||
|
||||
# First check whether this is actually an existing video
|
||||
video_oid = utils.str2id(video_id)
|
||||
video_doc = nodes_coll.find_one(video_oid, projection={
|
||||
'node_type': True,
|
||||
'properties.content_type': True,
|
||||
'properties.file': True,
|
||||
})
|
||||
if not video_doc:
|
||||
my_log.debug('Node %r not found, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
return no_video_response
|
||||
|
||||
try:
|
||||
is_video = (video_doc['node_type'] == 'asset'
|
||||
and video_doc['properties']['content_type'] == 'video')
|
||||
except KeyError:
|
||||
is_video = False
|
||||
|
||||
if not is_video:
|
||||
my_log.info('Node %r is not a video, unable to set progress for user %r',
|
||||
video_oid, current_user.user_id)
|
||||
# There is no video found at this URL, so act as if it doesn't even exist.
|
||||
return no_video_response
|
||||
|
||||
# Compute the progress
|
||||
percent = min(100, max(0, progress_in_perc))
|
||||
progress = {
|
||||
'progress_in_sec': progress_in_sec,
|
||||
'progress_in_percent': percent,
|
||||
'last_watched': utils.utcnow(),
|
||||
}
|
||||
|
||||
# After watching a certain percentage of the video, we consider it 'done'
|
||||
#
|
||||
# Total Credit start Total Credit Percent
|
||||
# HH:MM:SS HH:MM:SS sec sec of duration
|
||||
# Sintel 00:14:48 00:12:24 888 744 83.78%
|
||||
# Tears of Steel 00:12:14 00:09:49 734 589 80.25%
|
||||
# Cosmos Laundro 00:12:10 00:10:05 730 605 82.88%
|
||||
# Agent 327 00:03:51 00:03:26 231 206 89.18%
|
||||
# Caminandes 3 00:02:30 00:02:18 150 138 92.00%
|
||||
# Glass Half 00:03:13 00:02:52 193 172 89.12%
|
||||
# Big Buck Bunny 00:09:56 00:08:11 596 491 82.38%
|
||||
# Elephant’s Drea 00:10:54 00:09:25 654 565 86.39%
|
||||
#
|
||||
# Median 85.09%
|
||||
# Average 85.75%
|
||||
#
|
||||
# For training videos marking at done at 85% of the video may be a bit
|
||||
# early, since those probably won't have (long) credits. This is why we
|
||||
# stick to 90% here.
|
||||
if percent >= 90:
|
||||
progress['done'] = True
|
||||
|
||||
# Setting each property individually prevents us from overwriting any
|
||||
# existing {done: true} fields.
|
||||
updates = {f'nodes.view_progress.{video_id}.{k}': v
|
||||
for k, v in progress.items()}
|
||||
result = users_coll.update_one({'_id': current_user.user_id},
|
||||
{'$set': updates})
|
||||
|
||||
if result.matched_count == 0:
|
||||
my_log.error('Current user %r could not be updated', current_user.user_id)
|
||||
raise wz_exceptions.InternalServerError('Unable to find logged-in user')
|
||||
|
||||
return '', 204
|
||||
|
@ -391,7 +391,7 @@ class AbstractPillarTest(TestMinimal):
|
||||
|
||||
return user_id
|
||||
|
||||
def create_node(self, node_doc):
|
||||
def create_node(self, node_doc) -> ObjectId:
|
||||
"""Creates a node, returning its ObjectId. """
|
||||
|
||||
with self.app.test_request_context():
|
||||
|
182
src/scripts/video_plugins.js
Normal file
182
src/scripts/video_plugins.js
Normal file
@ -0,0 +1,182 @@
|
||||
/* Video.JS plugin for keeping track of user's viewing progress.
|
||||
Also registers the analytics plugin.
|
||||
|
||||
Progress is reported after a number of seconds or a percentage
|
||||
of the duration of the video, whichever comes first.
|
||||
|
||||
Example usage:
|
||||
|
||||
videojs(videoPlayerElement, options).ready(function() {
|
||||
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||
this.progressPlugin({'report_url': report_url});
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
// Report after progressing this many seconds video-time.
|
||||
let PROGRESS_REPORT_INTERVAL_SEC = 30;
|
||||
|
||||
// Report after progressing this percentage of the entire video (scale 0-100).
|
||||
let PROGRESS_REPORT_INTERVAL_PERC = 10;
|
||||
|
||||
// Don't report within this many milliseconds of wall-clock time of the previous report.
|
||||
let PROGRESS_RELAXING_TIME_MSEC = 500;
|
||||
|
||||
|
||||
var Plugin = videojs.getPlugin('plugin');
|
||||
var VideoProgressPlugin = videojs.extend(Plugin, {
|
||||
constructor: function(player, options) {
|
||||
Plugin.call(this, player, options);
|
||||
|
||||
this.last_wallclock_time_ms = 0;
|
||||
this.last_inspected_progress_in_sec = 0;
|
||||
this.last_reported_progress_in_sec = 0;
|
||||
this.last_reported_progress_in_perc = 0;
|
||||
this.report_url = options.report_url;
|
||||
this.fetch_progress_url = options.fetch_progress_url;
|
||||
this.reported_error = false;
|
||||
this.reported_looping = false;
|
||||
|
||||
if (typeof this.report_url === 'undefined' || !this.report_url) {
|
||||
/* If we can't report anything, don't bother registering event handlers. */
|
||||
videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
|
||||
} else {
|
||||
/* Those events will have 'this' bound to the player,
|
||||
* which is why we explicitly re-bind to 'this''. */
|
||||
player.on('timeupdate', this.on_timeupdate.bind(this));
|
||||
player.on('pause', this.on_pause.bind(this));
|
||||
}
|
||||
|
||||
if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
|
||||
/* If we can't report anything, don't bother registering event handlers. */
|
||||
videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
|
||||
} else {
|
||||
this.resume_playback();
|
||||
}
|
||||
},
|
||||
|
||||
resume_playback: function() {
|
||||
let on_done = function(progress, status, xhr) {
|
||||
/* 'progress' is an object like:
|
||||
{"progress_in_sec": 3,
|
||||
"progress_in_percent": 51,
|
||||
"last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
|
||||
"done": true}
|
||||
*/
|
||||
switch (xhr.status) {
|
||||
case 204: return; // no info found.
|
||||
case 200:
|
||||
/* Don't do anything when the progress is at 100%.
|
||||
* Moving the current time to the end makes no sense then. */
|
||||
if (progress.progress_in_percent >= 100) return;
|
||||
|
||||
/* Set the 'last reported' props before manipulating the
|
||||
* player, so that the manipulation doesn't trigger more
|
||||
* API calls to remember what we just restored. */
|
||||
this.last_reported_progress_in_sec = progress.progress_in_sec;
|
||||
this.last_reported_progress_in_perc = progress.progress_in_perc;
|
||||
|
||||
console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
|
||||
this.player.currentTime(progress.progress_in_sec);
|
||||
this.player.play();
|
||||
return;
|
||||
default:
|
||||
console.log("Unknown code", xhr.status, "getting video progress information.");
|
||||
}
|
||||
};
|
||||
|
||||
$.get(this.fetch_progress_url)
|
||||
.fail(function(error) {
|
||||
console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
|
||||
})
|
||||
.done(on_done.bind(this));
|
||||
},
|
||||
|
||||
/* Pausing playback should report the progress.
|
||||
* This function is also called when playback stops at the end of the video,
|
||||
* so it's important to report in this case; otherwise progress will never
|
||||
* reach 100%. */
|
||||
on_pause: function(event) {
|
||||
this.inspect_progress(true);
|
||||
},
|
||||
|
||||
on_timeupdate: function() {
|
||||
this.inspect_progress(false);
|
||||
},
|
||||
|
||||
inspect_progress: function(force_report) {
|
||||
// Don't report seeking when paused, only report actual playback.
|
||||
if (this.player.paused()) return;
|
||||
|
||||
let now_in_ms = new Date().getTime();
|
||||
if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
|
||||
// We're trying too fast, don't bother doing any other calculation.
|
||||
// console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
|
||||
return;
|
||||
}
|
||||
|
||||
let progress_in_sec = this.player.currentTime();
|
||||
let duration_in_sec = this.player.duration();
|
||||
|
||||
/* Instead of reporting the current time, report reaching the end
|
||||
* of the video. This ensures that it's properly marked as 'done'. */
|
||||
if (!this.reported_looping) {
|
||||
let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
|
||||
let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
|
||||
this.last_inspected_progress_in_sec = progress_in_sec;
|
||||
if (is_looping) {
|
||||
this.reported_looping = true;
|
||||
this.report(this.player.duration(), 100, now_in_ms);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
|
||||
// Already reported this, don't bother doing it again.
|
||||
return;
|
||||
}
|
||||
let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
|
||||
let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
|
||||
let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
|
||||
|
||||
if (!force_report
|
||||
&& Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
|
||||
&& Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.report(progress_in_sec, progress_in_perc, now_in_ms);
|
||||
},
|
||||
|
||||
report: function(progress_in_sec, progress_in_perc, now_in_ms) {
|
||||
/* Store when we tried, not when we succeeded. This function can be
|
||||
* called every 15-250 milliseconds, so we don't want to retry with
|
||||
* that frequency. */
|
||||
this.last_wallclock_time_ms = now_in_ms;
|
||||
|
||||
let on_fail = function(error) {
|
||||
/* Don't show (as in: a toastr popup) the error to the user,
|
||||
* as it doesn't impact their ability to play the video.
|
||||
* Also show the error only once, instead of spamming. */
|
||||
if (this.reported_error) return;
|
||||
|
||||
let msg = xhrErrorResponseMessage(error);
|
||||
console.log('Unable to report viewing progress:', msg);
|
||||
this.reported_error = true;
|
||||
};
|
||||
let on_done = function() {
|
||||
this.last_reported_progress_in_sec = progress_in_sec;
|
||||
this.last_reported_progress_in_perc = progress_in_perc;
|
||||
};
|
||||
|
||||
$.post(this.report_url, {
|
||||
progress_in_sec: progress_in_sec,
|
||||
progress_in_perc: Math.round(progress_in_perc),
|
||||
})
|
||||
.fail(on_fail.bind(this))
|
||||
.done(on_done.bind(this));
|
||||
},
|
||||
});
|
||||
|
||||
// Register our watch-progress-bookkeeping plugin.
|
||||
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);
|
@ -67,6 +67,14 @@ script(type="text/javascript").
|
||||
});
|
||||
this.hotkeys();
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
|
||||
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
|
||||
this.progressPlugin({
|
||||
'report_url': report_url,
|
||||
'fetch_progress_url': fetch_progress_url,
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function addVideoPlayerButton(data) {
|
||||
|
@ -74,6 +74,7 @@ link(rel="amphtml", href="{{ url_for('nodes.view', node_id=node._id, _external=T
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}")
|
||||
script(src="{{ url_for('static_pillar', filename='assets/js/video_plugins.min.js') }}")
|
||||
| {% endblock %}
|
||||
|
||||
| {% block css %}
|
||||
|
243
tests/test_api/test_user_video_progress.py
Normal file
243
tests/test_api/test_user_video_progress.py
Normal file
@ -0,0 +1,243 @@
|
||||
from unittest import mock
|
||||
|
||||
import bson
|
||||
from eve import RFC1123_DATE_FORMAT
|
||||
import flask
|
||||
|
||||
from pillar.tests import AbstractPillarTest
|
||||
|
||||
|
||||
class AbstractVideoProgressTest(AbstractPillarTest):
|
||||
def setUp(self, **kwargs):
|
||||
super().setUp(**kwargs)
|
||||
|
||||
self.pid, _ = self.ensure_project_exists()
|
||||
|
||||
self.admin_uid = self.create_user(24 * 'a', roles={'admin'})
|
||||
self.uid = self.create_user(24 * 'b', roles={'subscriber'})
|
||||
|
||||
from pillar.api.utils import utcnow
|
||||
self.fake_now = utcnow()
|
||||
self.fake_now_str = self.fake_now.strftime(RFC1123_DATE_FORMAT)
|
||||
|
||||
def create_video_node(self) -> bson.ObjectId:
|
||||
return self.create_node({
|
||||
'description': '',
|
||||
'node_type': 'asset',
|
||||
'user': self.admin_uid,
|
||||
'properties': {
|
||||
'status': 'published',
|
||||
'content_type': 'video',
|
||||
'file': bson.ObjectId()},
|
||||
'name': 'Image test',
|
||||
'project': self.pid,
|
||||
})
|
||||
|
||||
def set_progress(self,
|
||||
progress_in_sec: float = 413.0,
|
||||
progress_in_perc: int = 65,
|
||||
expected_status: int = 204) -> None:
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.set_video_progress', video_id=str(self.video_id))
|
||||
self.post(url,
|
||||
data={'progress_in_sec': progress_in_sec,
|
||||
'progress_in_perc': progress_in_perc},
|
||||
expected_status=expected_status)
|
||||
|
||||
def get_progress(self, expected_status: int = 200) -> dict:
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.get_video_progress', video_id=str(self.video_id))
|
||||
progress = self.get(url, expected_status=expected_status)
|
||||
return progress.json
|
||||
|
||||
def create_video_and_set_progress(self, progress_in_sec=413.0, progress_in_perc=65):
|
||||
self.video_id = self.create_video_node()
|
||||
|
||||
# Check that we can get the progress after setting it.
|
||||
with mock.patch('pillar.api.utils.utcnow') as utcnow:
|
||||
utcnow.return_value = self.fake_now
|
||||
self.set_progress(progress_in_sec, progress_in_perc)
|
||||
|
||||
|
||||
class HappyFlowVideoProgressTest(AbstractVideoProgressTest):
|
||||
|
||||
def test_video_progress_known_video(self):
|
||||
self.create_video_and_set_progress()
|
||||
progress = self.get_progress()
|
||||
|
||||
expected_progress = {
|
||||
'progress_in_sec': 413.0,
|
||||
'progress_in_percent': 65,
|
||||
'last_watched': self.fake_now,
|
||||
}
|
||||
self.assertEqual({**expected_progress, 'last_watched': self.fake_now_str},
|
||||
progress)
|
||||
|
||||
# Check that the database has been updated correctly.
|
||||
self.db_user = self.fetch_user_from_db(self.uid)
|
||||
self.assertEqual({str(self.video_id): expected_progress},
|
||||
self.db_user['nodes']['view_progress'])
|
||||
|
||||
def test_user_adheres_to_schema(self):
|
||||
from pillar.api.utils import remove_private_keys
|
||||
# This check is necessary because the API code uses direct MongoDB manipulation,
|
||||
# which means that the user can end up not matching the Cerberus schema.
|
||||
self.create_video_and_set_progress()
|
||||
db_user = self.fetch_user_from_db(self.uid)
|
||||
|
||||
r, _, _, status = self.app.put_internal(
|
||||
'users',
|
||||
payload=remove_private_keys(db_user),
|
||||
_id=db_user['_id'])
|
||||
self.assertEqual(200, status, r)
|
||||
|
||||
def test_video_progress_is_private(self):
|
||||
self.create_video_and_set_progress()
|
||||
|
||||
with self.login_as(self.uid):
|
||||
resp = self.get(f'/api/users/{self.uid}')
|
||||
self.assertIn('nodes', resp.json)
|
||||
|
||||
other_uid = self.create_user(24 * 'c', roles={'subscriber'})
|
||||
with self.login_as(other_uid):
|
||||
resp = self.get(f'/api/users/{self.uid}')
|
||||
self.assertIn('username', resp.json) # just to be sure this is a real user response
|
||||
self.assertNotIn('nodes', resp.json)
|
||||
|
||||
def test_done_at_100_percent(self):
|
||||
self.create_video_and_set_progress(630, 100)
|
||||
progress = self.get_progress()
|
||||
self.assertEqual({'progress_in_sec': 630.0,
|
||||
'progress_in_percent': 100,
|
||||
'last_watched': self.fake_now_str,
|
||||
'done': True},
|
||||
progress)
|
||||
|
||||
def test_done_at_95_percent(self):
|
||||
self.create_video_and_set_progress(599, 95)
|
||||
progress = self.get_progress()
|
||||
self.assertEqual({'progress_in_sec': 599.0,
|
||||
'progress_in_percent': 95,
|
||||
'last_watched': self.fake_now_str,
|
||||
'done': True},
|
||||
progress)
|
||||
|
||||
def test_rewatch_after_done(self):
|
||||
from pillar.api.utils import utcnow
|
||||
|
||||
self.create_video_and_set_progress(630, 100)
|
||||
|
||||
# Re-watching should keep the 'done' key.
|
||||
another_fake_now = utcnow()
|
||||
with mock.patch('pillar.api.utils.utcnow') as mock_utcnow:
|
||||
mock_utcnow.return_value = another_fake_now
|
||||
self.set_progress(444, 70)
|
||||
|
||||
progress = self.get_progress()
|
||||
self.assertEqual({'progress_in_sec': 444,
|
||||
'progress_in_percent': 70,
|
||||
'done': True,
|
||||
'last_watched': another_fake_now.strftime(RFC1123_DATE_FORMAT)},
|
||||
progress)
|
||||
|
||||
def test_inconsistent_progress(self):
|
||||
# Send a percentage that's incorrect. It should just be copied.
|
||||
self.create_video_and_set_progress(413.557, 30)
|
||||
progress = self.get_progress()
|
||||
|
||||
expected_progress = {
|
||||
'progress_in_sec': 413.557,
|
||||
'progress_in_percent': 30,
|
||||
'last_watched': self.fake_now,
|
||||
}
|
||||
self.assertEqual({**expected_progress, 'last_watched': self.fake_now_str},
|
||||
progress)
|
||||
|
||||
# Check that the database has been updated correctly.
|
||||
self.db_user = self.fetch_user_from_db(self.uid)
|
||||
self.assertEqual({str(self.video_id): expected_progress},
|
||||
self.db_user['nodes']['view_progress'])
|
||||
|
||||
|
||||
class UnhappyFlowVideoProgressTest(AbstractVideoProgressTest):
|
||||
def test_get_video_progress_invalid_video_id(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.get_video_progress', video_id='jemoeder')
|
||||
self.get(url, expected_status=400)
|
||||
|
||||
def test_get_video_progress_unknown_video(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.get_video_progress', video_id=24 * 'f')
|
||||
self.get(url, expected_status=204)
|
||||
|
||||
def test_set_video_progress_unknown_video(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f')
|
||||
self.post(url,
|
||||
data={'progress_in_sec': 16, 'progress_in_perc': 10},
|
||||
expected_status=404)
|
||||
|
||||
def test_set_video_progress_invalid_video_id(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.set_video_progress', video_id='jemoeder')
|
||||
self.post(url,
|
||||
data={'progress_in_sec': 16, 'progress_in_perc': 10},
|
||||
expected_status=400)
|
||||
|
||||
def test_get_video_empty_dict(self):
|
||||
self.video_id = bson.ObjectId(24 * 'f')
|
||||
with self.app.app_context():
|
||||
users_coll = self.app.db('users')
|
||||
# The progress dict for that video is there, but empty.
|
||||
users_coll.update_one(
|
||||
{'_id': self.uid},
|
||||
{'$set': {f'nodes.view_progress.{self.video_id}': {}}})
|
||||
|
||||
progress = self.get_progress(expected_status=204)
|
||||
self.assertIsNone(progress)
|
||||
|
||||
def test_missing_post_field(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f')
|
||||
self.post(url, data={'progress_in_ms': 1000}, expected_status=400)
|
||||
|
||||
def test_nonint_progress(self):
|
||||
with self.login_as(self.uid):
|
||||
url = flask.url_for('users_api.set_video_progress', video_id=24 * 'f')
|
||||
self.post(url, data={'progress_in_sec': 'je moeder'}, expected_status=400)
|
||||
|
||||
def test_asset_is_valid_but_not_video(self):
|
||||
self.video_id = self.create_node({
|
||||
'description': '',
|
||||
'node_type': 'asset',
|
||||
'user': self.admin_uid,
|
||||
'properties': {
|
||||
'status': 'published',
|
||||
'content_type': 'image', # instead of video
|
||||
'file': bson.ObjectId()},
|
||||
'name': 'Image test',
|
||||
'project': self.pid,
|
||||
})
|
||||
|
||||
with mock.patch('pillar.api.utils.utcnow') as utcnow:
|
||||
utcnow.return_value = self.fake_now
|
||||
self.set_progress(expected_status=404)
|
||||
self.get_progress(expected_status=204)
|
||||
|
||||
def test_asset_malformed(self):
|
||||
self.video_id = self.create_node({
|
||||
'description': '',
|
||||
'node_type': 'asset',
|
||||
'user': self.admin_uid,
|
||||
'properties': {
|
||||
'status': 'published',
|
||||
# Note the lack of a 'content_type' key.
|
||||
'file': bson.ObjectId()},
|
||||
'name': 'Missing content_type test',
|
||||
'project': self.pid,
|
||||
})
|
||||
|
||||
with mock.patch('pillar.api.utils.utcnow') as utcnow:
|
||||
utcnow.return_value = self.fake_now
|
||||
self.set_progress(expected_status=404)
|
||||
self.get_progress(expected_status=204)
|
Loading…
x
Reference in New Issue
Block a user