PageRenderTime 23ms CodeModel.GetById 19ms RepoModel.GetById 0ms app.codeStats 0ms

/kalite/distributed/static/js/distributed/exercises/models.js

https://gitlab.com/gregtyka/ka-lite
JavaScript | 656 lines | 433 code | 158 blank | 65 comment | 59 complexity | 3ed8d9359e520e95378b08e9abf329b9 MD5 | raw file
  1. var Backbone = require("base/backbone");
  2. var _ = require("underscore");
  3. var seeded_shuffle = require("utils/shuffle");
  4. var get_params = require("utils/get_params");
  5. var seedrandom = require("seedrandom");
  6. var ContentModels = require("content/models");
  7. var ds = window.ds || {};
  8. var ExerciseParams = {
  9. STREAK_CORRECT_NEEDED: (ds.distributed || {}).streak_correct_needed || 8,
  10. STREAK_WINDOW: 10,
  11. FIXED_BLOCK_EXERCISES: (ds.distributed || {}).fixed_block_exercises || 0
  12. };
  13. var ExerciseDataModel = ContentModels.ContentDataModel.extend({
  14. /*
  15. Contains data about an exercise itself, with no user-specific data.
  16. */
  17. defaults: {
  18. basepoints: 0,
  19. description: "",
  20. title: "",
  21. name: "",
  22. seconds_per_fast_problem: 0,
  23. author_name: "",
  24. related_videos: [],
  25. file_name: ""
  26. },
  27. initialize: function() {
  28. _.bindAll(this, "url", "update_if_needed_then", "as_user_exercise", "get_framework");
  29. var self = this;
  30. // store the provided seed as an object attribute, so it will be available after a fetch
  31. this.listenTo(this, "change:seed", function() { self.seed = self.get("seed") || self.seed; });
  32. },
  33. update_if_needed_then: function(callback) {
  34. // TODO(jamalex): use a better method for checking status of lazy loading
  35. if (this.get("id") !== this.get("name")) {
  36. this.fetch().then(callback);
  37. } else {
  38. _.defer(callback);
  39. }
  40. },
  41. // convert this data into the structure needed by khan-exercises
  42. as_user_exercise: function () {
  43. return {
  44. "basepoints": this.get("basepoints"),
  45. "description": this.get("description"),
  46. "title": this.get("display_name"),
  47. "seed": this.seed,
  48. "lastCountHints": 0, // TODO: could store and pass down number of hints used
  49. "exerciseModel": {
  50. "displayName": this.get("display_name"),
  51. "name": this.get("name"),
  52. "secondsPerFastProblem": this.get("seconds_per_fast_problem"),
  53. "authorName": this.get("author_name"),
  54. "relatedVideos": this.get("related_videos"),
  55. "fileName": this.get("file_name")
  56. },
  57. "exerciseProgress": {
  58. "level": "" // needed to keep khan-exercises from blowing up
  59. }
  60. };
  61. },
  62. get_framework: function() {
  63. return this.get("uses_assessment_items") ? "perseus" : "khan-exercises";
  64. }
  65. });
  66. var AssessmentItemModel = Backbone.Model.extend({
  67. urlRoot: function() {
  68. var base = window.sessionModel.get("ALL_ASSESSMENT_ITEMS_URL"); // Has a trailing '/'
  69. return base.slice(0, base.length - 1); // Remove it so the url can be properly built.
  70. },
  71. get_item_data: function() {
  72. return JSON.parse(this.get("item_data"));
  73. }
  74. });
  75. var ExerciseLogModel = Backbone.Model.extend({
  76. /*
  77. Contains summary data about the user's history of interaction with the current exercise.
  78. */
  79. defaults: {
  80. streak_progress: 0,
  81. points: 0,
  82. attempts: 0
  83. },
  84. initialize: function() {
  85. _.bindAll(this, "save", "attempts_since_completion", "fixed_block_questions_remaining");
  86. },
  87. save: function() {
  88. var self = this;
  89. var already_complete = this.get("complete");
  90. if (this.get("attempts") > 20 && !this.get("complete")) {
  91. this.set("struggling", true);
  92. }
  93. this.set("complete", this.get("streak_progress") >= 100);
  94. if (!already_complete && this.get("complete")) {
  95. this.set({
  96. "struggling": false,
  97. "completion_timestamp": window.statusModel.get_server_time(),
  98. "attempts_before_completion": this.get("attempts")
  99. }, {silent: true});
  100. }
  101. this.set("latest_activity_timestamp", window.statusModel.get_server_time(), {silent: true});
  102. // call the super method that will actually do the saving
  103. return Backbone.Model.prototype.save.call(this);
  104. },
  105. attempts_since_completion: function() {
  106. if (!this.get("complete")) {
  107. return 0;
  108. }
  109. return this.get("attempts") - this.get("attempts_before_completion");
  110. },
  111. fixed_block_questions_remaining: function() {
  112. return ExerciseParams.FIXED_BLOCK_EXERCISES - this.attempts_since_completion();
  113. },
  114. urlRoot: function() {
  115. return window.sessionModel.get("GET_EXERCISE_LOGS_URL");
  116. },
  117. });
  118. var ExerciseLogCollection = ContentModels.ContentLogCollection.extend({
  119. model: ExerciseLogModel,
  120. model_id_key: "exercise_id",
  121. get_first_log_or_new_log: function() {
  122. if (this.length > 0) {
  123. return this.at(0);
  124. } else { // create a new exercise log if none existed
  125. var data = {
  126. "user": window.statusModel.get("user_uri")
  127. };
  128. data[this.model_id_key] = this.content_model.get("id");
  129. return new this.model(data);
  130. }
  131. }
  132. });
  133. var AttemptLogModel = Backbone.Model.extend({
  134. /*
  135. Contains data about the user's response to a particular exercise instance.
  136. */
  137. urlRoot: function() {
  138. return window.sessionModel.get("GET_ATTEMPT_LOGS_URL");
  139. },
  140. defaults: {
  141. complete: false,
  142. points: 0,
  143. context_type: "",
  144. context_id: "",
  145. response_count: 0
  146. },
  147. to_object: function() {
  148. return _.clone(this.attributes);
  149. },
  150. add_response_log_event: function(ev) {
  151. var response_log = this.get("response_log") || [];
  152. // set the timestamp to the current time
  153. ev.timestamp = window.statusModel.get_server_time();
  154. // add the event to the response log list
  155. response_log.push(ev);
  156. this.set("response_log", response_log);
  157. },
  158. parse: function(response) {
  159. if (response) {
  160. if (response.response_log) {
  161. response.response_log = JSON.parse(response.response_log);
  162. }
  163. }
  164. return response;
  165. },
  166. toJSON: function(options) {
  167. var output = Backbone.Model.prototype.toJSON.call(this);
  168. if (output.response_log) {
  169. output.response_log = JSON.stringify(output.response_log);
  170. }
  171. return output;
  172. }
  173. });
  174. var AttemptLogCollection = Backbone.Collection.extend({
  175. model: AttemptLogModel,
  176. initialize: function(models, options) {
  177. this.filters = $.extend({
  178. "user": window.statusModel.get("user_id"),
  179. "limit": ExerciseParams.STREAK_WINDOW
  180. }, options);
  181. },
  182. url: function() {
  183. return get_params.setGetParamDict(this.model.prototype.urlRoot(), this.filters);
  184. },
  185. to_objects: function() {
  186. return this.map(function(model){ return model.to_object(); });
  187. },
  188. add_new: function(attemptlog) {
  189. if (this.length == ExerciseParams.STREAK_WINDOW) {
  190. this.pop();
  191. }
  192. this.unshift(attemptlog);
  193. },
  194. get_streak_progress: function() {
  195. var count = 0;
  196. this.forEach(function(model) {
  197. count += model.get("correct") ? 1 : 0;
  198. });
  199. return count;
  200. },
  201. get_streak_progress_percent: function() {
  202. var streak_progress = this.get_streak_progress();
  203. return Math.min((streak_progress / ExerciseParams.STREAK_CORRECT_NEEDED) * 100, 100);
  204. },
  205. get_streak_points: function() {
  206. // only include attempts that were correct (others won't have points)
  207. var filtered_attempts = this.filter(function(attempt) { return attempt.get("correct"); });
  208. // add up and return the total number of points represented by these attempts
  209. // (only include the latest STREAK_CORRECT_NEEDED attempts, so the user doesn't get too many points)
  210. var total = 0;
  211. for (var i = 0; i < Math.min(ExerciseParams.STREAK_CORRECT_NEEDED, filtered_attempts.length); i++) {
  212. total += filtered_attempts[i].get("points");
  213. }
  214. return total;
  215. },
  216. calculate_points_per_question: function(basepoints) {
  217. // for comparability with the original algorithm (when a streak of 10 was needed),
  218. // we calibrate the points awarded for each question (note that there are no random bonuses now)
  219. return Math.round((basepoints * 10) / ExerciseParams.STREAK_CORRECT_NEEDED);
  220. }
  221. });
  222. var TestDataModel = Backbone.Model.extend({
  223. /*
  224. Contains data about a particular student test.
  225. */
  226. url: function() {
  227. return "/test/api/test/" + this.get("test_id") + "/";
  228. }
  229. });
  230. var TestLogModel = Backbone.Model.extend({
  231. /*
  232. Contains summary data about the user's history of interaction with the current test.
  233. */
  234. defaults: {
  235. index: 0,
  236. complete: false,
  237. started: false
  238. },
  239. init: function(options) {
  240. _.bindAll(this, "get_item_data", "save");
  241. },
  242. get_item_data: function(test_data_model) {
  243. /*
  244. This function is designed to give a deterministic test sequence for an individual, based
  245. on their userModel URI. As such, each individual will always have the same generated test
  246. sequence, but it is, for all intents and purposes, randomized across individuals.
  247. */
  248. /*
  249. Seed random generator here so that it increments all seed randomization blocks.
  250. If seeded inside each call to the function, then the blocks of seeds for each user
  251. would be identically shuffled.
  252. */
  253. // TODO (rtibbles): qUnit or other javascript unit testing to set up tests for this code.
  254. if(typeof(test_data_model)==="object"){
  255. var random = seedrandom(this.get("user"));
  256. var items = $.parseJSON(test_data_model.get("ids"));
  257. var initial_seed = test_data_model.get("seed");
  258. var repeats = test_data_model.get("repeats");
  259. // Final seed and item sequences.
  260. this.seed_sequence = [];
  261. this.item_sequence = [];
  262. /*
  263. Loop over every repeat, adding each exercise_id in turn to item_sequence.
  264. Increment initial_seed on each inner iteration to give unique seeds across
  265. all exercises. This will prevent similarly generated exercises from appearing identical.
  266. This will have the net effect of a fixed sequence of exercise_ids, repeating
  267. 'repeats' times. Build seed sequences per item, so that sequence of seeds can be shuffled
  268. per item, giving the net result that across tests, the seed/item pairs are matched, but the
  269. order the seeds appear in within the item repeat blocks is different for each test taker.
  270. */
  271. var item_seed_sequence = [];
  272. for(j=0; j < repeats; j++){
  273. for(i=0; i < items.length; i++){
  274. if(j===0){
  275. item_seed_sequence[i] = [];
  276. }
  277. this.item_sequence.push(items[i]);
  278. item_seed_sequence[i].push(initial_seed);
  279. initial_seed+=1;
  280. }
  281. }
  282. for(i=0; i < items.length; i++){
  283. item_seed_sequence[i] = seeded_shuffle(item_seed_sequence[i], random);
  284. }
  285. for(j=0; j < repeats; j++){
  286. for(i=0; i < items.length; i++){
  287. this.seed_sequence.push(item_seed_sequence[i][j]);
  288. }
  289. }
  290. }
  291. return {
  292. seed: this.seed_sequence[this.get("index")],
  293. exercise_id: this.item_sequence[this.get("index")]
  294. };
  295. },
  296. save: function() {
  297. var self = this;
  298. var already_complete = this.get("complete");
  299. if(this.item_sequence){
  300. if(!this.get("total_number")){
  301. this.set({
  302. total_number: this.item_sequence.length
  303. });
  304. }
  305. if((this.get("index") == this.item_sequence.length) && !already_complete){
  306. this.set({
  307. complete: true
  308. });
  309. this.trigger("complete");
  310. }
  311. }
  312. Backbone.Model.prototype.save.call(this);
  313. },
  314. urlRoot: "/test/api/testlog/"
  315. });
  316. var TestLogCollection = Backbone.Collection.extend({
  317. model: TestLogModel,
  318. initialize: function(models, options) {
  319. this.test_id = options.test_id;
  320. },
  321. url: function() {
  322. return "/test/api/testlog/?" + $.param({
  323. "test": this.test_id,
  324. "user": window.statusModel.get("user_id")
  325. });
  326. },
  327. get_first_log_or_new_log: function() {
  328. if (this.length > 0) {
  329. return this.at(0);
  330. } else { // create a new exercise log if none existed
  331. return new TestLogModel({
  332. "user": window.statusModel.get("user_uri"),
  333. "test": this.test_id
  334. });
  335. }
  336. }
  337. });
  338. var QuizDataModel = Backbone.Model.extend({
  339. defaults: {
  340. repeats: (ds.distributed || {}).quiz_repeats || 3
  341. },
  342. initialize: function() {
  343. this.set({
  344. ids: this.get_exercise_ids_from_playlist_entry(this.get("entry")),
  345. quiz_id: this.get("entry").get("entity_id"),
  346. seed: this.get("entry").get("seed") || null
  347. });
  348. },
  349. get_exercise_ids_from_playlist_entry: function(entry) {
  350. var temp_collection = entry.collection.slice(0, _.indexOf(entry.collection, entry));
  351. var left_index = _.reduceRight(entry.collection.slice(0, _.indexOf(entry.collection, entry)), function(memo, value, index){
  352. if(!memo && value.get("entity_kind")==="Quiz"){
  353. return index;
  354. } else {
  355. return memo;
  356. }
  357. }, 0);
  358. return _.map(new Backbone.Collection(temp_collection.slice(left_index)).where({"entity_kind": "Exercise"}), function(value){return value.get("entity_id");});
  359. }
  360. });
  361. var QuizLogModel = Backbone.Model.extend({
  362. /*
  363. Contains summary data about the user's history of interaction with the current test.
  364. */
  365. defaults: {
  366. index: 0,
  367. complete: false,
  368. attempts: 0,
  369. total_correct: 0
  370. },
  371. init: function(options) {
  372. _.bindAll(this, "get_item_data", "save", "add_response_log_item", "get_latest_response_log_item");
  373. var self = this;
  374. },
  375. get_item_data: function(quiz_data_model) {
  376. /*
  377. This function is designed to give a deterministic quiz sequence for an individual, based
  378. on their userModel URI. As such, each individual will always have the same generated quiz
  379. sequence, but it is, for all intents and purposes, randomized across individuals.
  380. */
  381. /*
  382. Seed random generator here so that it increments all seed randomization blocks.
  383. If seeded inside each call to the function, then the blocks of seeds for each user
  384. would be identically shuffled.
  385. */
  386. if(typeof(quiz_data_model)==="object"){
  387. var random = seedrandom(this.get("user") + this.get("attempts"));
  388. var items = quiz_data_model.get("ids");
  389. var repeats = quiz_data_model.get("repeats");
  390. var initial_seed = seedrandom(this.get("user") + this.get("attempts"))()*1000;
  391. this.item_sequence = [];
  392. this.seed_sequence = [];
  393. for(j=0; j < repeats; j++){
  394. this.item_sequence.push(items);
  395. for(i=0; i < items.length; i++){
  396. this.seed_sequence.push(initial_seed);
  397. initial_seed+=1;
  398. }
  399. }
  400. this.item_sequence = _.flatten(this.item_sequence);
  401. this.item_sequence = seeded_shuffle(this.item_sequence, random);
  402. }
  403. return {
  404. exercise_id: this.item_sequence[this.get("index")],
  405. seed: this.seed_sequence[this.get("index")]
  406. };
  407. },
  408. save: function() {
  409. var self = this;
  410. var already_complete = this.get("complete");
  411. if(this.item_sequence){
  412. if(!this.get("total_number")){
  413. this.set({
  414. total_number: this.item_sequence.length
  415. });
  416. }
  417. if((this.get("index") == this.item_sequence.length)){
  418. this.set({
  419. index: 0,
  420. attempts: this.get("attempts") + 1
  421. });
  422. if(!already_complete) {
  423. this.set({
  424. complete: true
  425. });
  426. }
  427. this.trigger("complete");
  428. }
  429. }
  430. Backbone.Model.prototype.save.call(this);
  431. },
  432. add_response_log_item: function(data) {
  433. // inflate the stored JSON if needed
  434. if (!this._response_log_cache) {
  435. this._response_log_cache = JSON.parse(this.get("response_log") || "[]");
  436. }
  437. if(!this._response_log_cache[this.get("attempts")]){
  438. this._response_log_cache.push(0);
  439. }
  440. // add the event to the response log list
  441. if(data.correct){
  442. this._response_log_cache[this.get("attempts")] += 1;
  443. if(this.get("attempts")===0) {
  444. this.set({
  445. total_correct: this.get("total_correct") + 1
  446. });
  447. }
  448. }
  449. // deflate the response log list so it will be saved along with the model later
  450. this.set("response_log", JSON.stringify(this._response_log_cache));
  451. },
  452. get_latest_response_log_item: function() {
  453. // inflate the stored JSON if needed
  454. if (!this._response_log_cache) {
  455. this._response_log_cache = JSON.parse(this.get("response_log") || "[]");
  456. }
  457. // add the event to the response log list
  458. return this._response_log_cache[this.get("attempts")-1];
  459. },
  460. urlRoot: "/api/playlists/quizlog/"
  461. });
  462. var QuizLogCollection = Backbone.Collection.extend({
  463. model: QuizLogModel,
  464. initialize: function(models, options) {
  465. this.quiz = options.quiz;
  466. },
  467. url: function() {
  468. return "/api/playlists/quizlog/?" + $.param({
  469. "quiz": this.quiz,
  470. "user": window.statusModel.get("user_id")
  471. });
  472. },
  473. get_first_log_or_new_log: function() {
  474. if (this.length > 0) {
  475. return this.at(0);
  476. } else { // create a new exercise log if none existed
  477. return new QuizLogModel({
  478. "user": window.statusModel.get("user_uri"),
  479. "quiz": this.quiz
  480. });
  481. }
  482. }
  483. });
  484. module.exports = {
  485. ExerciseParams: ExerciseParams,
  486. ExerciseDataModel: ExerciseDataModel,
  487. ExerciseLogModel: ExerciseLogModel,
  488. ExerciseLogCollection: ExerciseLogCollection,
  489. AssessmentItemModel: AssessmentItemModel,
  490. AttemptLogModel: AttemptLogModel,
  491. AttemptLogCollection: AttemptLogCollection,
  492. TestDataModel: TestDataModel,
  493. TestLogModel: TestLogModel,
  494. TestLogCollection: TestLogCollection,
  495. QuizDataModel: QuizDataModel,
  496. QuizLogModel: QuizLogModel,
  497. QuizLogCollection: QuizLogCollection
  498. };