Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
| Download
Views: 39598
1
##############################################################################
2
#
3
# CoCalc: Collaborative Calculation in the Cloud
4
#
5
# Copyright (C) 2016, Sagemath Inc.
6
#
7
# This program is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License as published by
9
# the Free Software Foundation, either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# This program is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
#
20
###############################################################################
21
22
# 3rd party libs
23
async = require('async')
24
markdownlib = require('../markdown')
25
26
# CoCalc libraries
27
misc = require('smc-util/misc')
28
{defaults, required} = misc
29
schema = require('smc-util/schema')
30
{webapp_client} = require('../webapp_client')
31
32
# Course Library
33
{STEPS, previous_step, step_direction, step_verb, step_ready} = require('./util')
34
35
# React libraries
36
{Actions, Store} = require('../smc-react')
37
38
PARALLEL_LIMIT = 5 # number of async things to do in parallel
39
40
primary_key =
41
students : 'student_id'
42
assignments : 'assignment_id'
43
handouts : 'handout_id'
44
45
# Requires a syncdb to be set later
46
# Manages local and sync changes
47
exports.CourseActions = class CourseActions extends Actions
48
constructor: (@name, @redux) ->
49
if not @name?
50
throw Error("@name must be defined")
51
if not @redux?
52
throw Error("@redux must be defined")
53
@get_store = () => @redux.getStore(@name)
54
55
_loaded: =>
56
if not @syncdb?
57
@set_error("attempt to set syncdb before loading")
58
return false
59
return true
60
61
_store_is_initialized: =>
62
store = @get_store()
63
return if not store?
64
if not (store.get('students')? and store.get('assignments')? and store.get('settings')? and store.get('handouts'))
65
@set_error("store must be initialized")
66
return false
67
return true
68
69
# Set one object in the syncdb
70
_set: (obj) =>
71
if not @_loaded() or @syncdb?.is_closed()
72
return
73
@syncdb.set(obj)
74
75
# Get one object from @syncdb as a Javascript object (or undefined)
76
_get_one: (obj) =>
77
if @syncdb?.is_closed()
78
return
79
return @syncdb.get_one(obj)?.toJS()
80
81
set_tab: (tab) =>
82
@setState(tab:tab)
83
84
save: =>
85
store = @get_store()
86
return if not store? # e.g., if the course store object already gone due to closing course.
87
if store.get('saving')
88
return # already saving
89
id = @set_activity(desc:"Saving...")
90
@setState(saving:true)
91
@syncdb.save (err) =>
92
@clear_activity(id)
93
@setState(saving:false)
94
@setState(unsaved:@syncdb?.has_unsaved_changes())
95
if err
96
@set_error("Error saving -- #{err}")
97
@setState(show_save_button:true)
98
else
99
@setState(show_save_button:false)
100
101
_syncdb_change: (changes) =>
102
# console.log('_syncdb_change', JSON.stringify(changes.toJS()))
103
store = @get_store()
104
return if not store?
105
cur = t = store.getState()
106
changes.map (obj) =>
107
table = obj.get('table')
108
if not table?
109
# no idea what to do with something that doesn't have table defined
110
return
111
x = @syncdb.get_one(obj)
112
key = primary_key[table]
113
if not x?
114
# delete
115
if key?
116
t = t.set(table, t.get(table).delete(obj.get(key)))
117
else
118
# edit or insert
119
if key?
120
t = t.set(table, t.get(table).set(x.get(key), x))
121
else if table == 'settings'
122
t = t.set(table, t.get(table).merge(x.delete('table')))
123
else
124
# no idea what to do with this
125
console.warn("unknown table '#{table}'")
126
return # ensure map doesn't terminate
127
128
if not cur.equals(t) # something definitely changed
129
@setState(t)
130
@setState(unsaved:@syncdb?.has_unsaved_changes())
131
132
handle_projects_store_update: (state) =>
133
store = @get_store()
134
return if not store?
135
users = state.getIn(['project_map', store.get('course_project_id'), 'users'])?.keySeq()
136
if not users?
137
return
138
if not @_last_collaborator_state?
139
@_last_collaborator_state = users
140
return
141
if not @_last_collaborator_state.equals(users)
142
@configure_all_projects()
143
@_last_collaborator_state = users
144
145
# PUBLIC API
146
147
set_error: (error) =>
148
if error == ''
149
@setState(error:error)
150
else
151
@setState(error:((@get_store()?.get('error') ? '') + '\n' + error).trim())
152
153
set_activity: (opts) =>
154
opts = defaults opts,
155
id : undefined
156
desc : undefined
157
if not opts.id? and not opts.desc?
158
return
159
if not opts.id?
160
@_activity_id = (@_activity_id ? 0) + 1
161
opts.id = @_activity_id
162
store = @get_store()
163
if not store? # course was closed
164
return
165
x = store.get_activity()?.toJS()
166
if not x?
167
x = {}
168
if not opts.desc?
169
delete x[opts.id]
170
else
171
x[opts.id] = opts.desc
172
@setState(activity: x)
173
return opts.id
174
175
clear_activity: (id) =>
176
if id?
177
@set_activity(id:id) # clears for this id
178
else
179
@setState(activity:{})
180
181
# Settings
182
set_title: (title) =>
183
@_set(title:title, table:'settings')
184
@set_all_student_project_titles(title)
185
@set_shared_project_title()
186
187
set_description: (description) =>
188
@_set(description:description, table:'settings')
189
@set_all_student_project_descriptions(description)
190
@set_shared_project_description()
191
192
set_upgrade_goal: (upgrade_goal) =>
193
@_set(upgrade_goal:upgrade_goal, table:'settings')
194
195
set_allow_collabs: (allow_collabs) =>
196
@_set(allow_collabs:allow_collabs, table:'settings')
197
@configure_all_projects()
198
199
set_email_invite: (body) =>
200
@_set(email_invite:body, table:'settings')
201
202
# return the default title and description of the shared project.
203
shared_project_settings: (title) =>
204
store = @get_store()
205
return if not store?
206
x =
207
title : "Shared Project -- #{title ? store.getIn(['settings', 'title'])}"
208
description : store.getIn(['settings', 'description']) + "\n---\n This project is shared with all students."
209
return x
210
211
set_shared_project_title: =>
212
store = @get_store()
213
shared_id = store?.get_shared_project_id()
214
return if not store? or not shared_id
215
216
title = @shared_project_settings().title
217
@redux.getActions('projects').set_project_title(shared_id, title)
218
219
set_shared_project_description: =>
220
store = @get_store()
221
shared_id = store?.get_shared_project_id()
222
return if not store? or not shared_id
223
224
description = @shared_project_settings().description
225
@redux.getActions('projects').set_project_description(shared_id, description)
226
227
# start the shared project running (if it is defined)
228
action_shared_project: (action) =>
229
if action not in ['start', 'stop', 'restart']
230
throw Error("action must be start, stop or restart")
231
store = @get_store()
232
return if not store?
233
shared_project_id = store.get_shared_project_id()
234
if not shared_project_id
235
return # no shared project
236
@redux.getActions('projects')[action+"_project"]?(shared_project_id)
237
238
# configure the shared project so that it has everybody as collaborators
239
configure_shared_project: =>
240
store = @get_store()
241
return if not store?
242
shared_project_id = store.get_shared_project_id()
243
if not shared_project_id
244
return # no shared project
245
@set_shared_project_title()
246
# add collabs -- all collaborators on course project and all students
247
projects = @redux.getStore('projects')
248
shared_project_users = projects.get_users(shared_project_id)
249
if not shared_project_users?
250
return
251
course_project_users = projects.get_users(store.get('course_project_id'))
252
if not course_project_users?
253
return
254
student_account_ids = {}
255
store.get_students().map (student, _) =>
256
if not student.get('deleted')
257
account_id = student.get('account_id')
258
if account_id?
259
student_account_ids[account_id] = true
260
261
# Each of shared_project_users or course_project_users are
262
# immutable.js maps from account_id's to something, and students is a map from
263
# the student account_id's.
264
# Our goal is to ensur that:
265
# {shared_project_users} = {course_project_users} union {students}.
266
267
actions = @redux.getActions('projects')
268
if not store.get_allow_collabs()
269
# Ensure the shared project users are all either course or students
270
shared_project_users.map (_, account_id) =>
271
if not course_project_users.get(account_id) and not student_account_ids[account_id]
272
actions.remove_collaborator(shared_project_id, account_id)
273
# Ensure every course project user is on the shared project
274
course_project_users.map (_, account_id) =>
275
if not shared_project_users.get(account_id)
276
actions.invite_collaborator(shared_project_id, account_id)
277
# Ensure every student is on the shared project
278
for account_id, _ of student_account_ids
279
if not shared_project_users.get(account_id)
280
actions.invite_collaborator(shared_project_id, account_id)
281
282
# set the shared project id in our syncdb
283
_set_shared_project_id: (project_id) =>
284
@_set
285
table : 'settings'
286
shared_project_id : project_id
287
288
# create the globally shared project if it doesn't exist
289
create_shared_project: () =>
290
store = @get_store()
291
return if not store?
292
if store.get_shared_project_id()
293
return
294
id = @set_activity(desc:"Creating global shared project for everybody.")
295
x = @shared_project_settings()
296
x.token = misc.uuid()
297
@redux.getActions('projects').create_project(x)
298
@redux.getStore('projects').wait_until_project_created x.token, 30, (err, project_id) =>
299
@clear_activity(id)
300
if err
301
@set_error("error creating shared project -- #{err}")
302
else
303
@_set_shared_project_id(project_id)
304
@configure_shared_project()
305
306
# Set the pay option for the course, and ensure that the course fields are
307
# set on every student project in the course (see schema.coffee for format
308
# of the course field) to reflect this change in the database.
309
set_course_info: (pay='') =>
310
@_set
311
pay : pay
312
table : 'settings'
313
@set_all_student_project_course_info(pay)
314
315
# Takes an item_name and the id of the time
316
# item_name should be one of
317
# ['student', 'assignment', 'peer_config', handout']
318
toggle_item_expansion: (item_name, item_id) =>
319
store = @get_store()
320
return if not store?
321
field_name = "expanded_#{item_name}s"
322
expanded_items = store.get(field_name)
323
if expanded_items.has(item_id)
324
adjusted = expanded_items.delete(item_id)
325
else
326
adjusted = expanded_items.add(item_id)
327
@setState("#{field_name}" : adjusted)
328
329
# Students
330
add_students: (students) =>
331
# students = array of account_id or email_address
332
# New student_id's will be constructed randomly for each student
333
student_ids = []
334
for x in students
335
student_id = misc.uuid()
336
student_ids.push(student_id)
337
x.table = 'students'
338
x.student_id = student_id
339
@syncdb.set(x)
340
f = (student_id, cb) =>
341
async.series([
342
(cb) =>
343
store = @get_store()
344
if not store?
345
cb("store not defined"); return
346
store.wait
347
until : (store) => store.get_student(student_id)
348
timeout : 60
349
cb : cb
350
(cb) =>
351
@create_student_project(student_id)
352
store = @get_store()
353
if not store?
354
cb("store not defined"); return
355
store.wait
356
until : (store) => store.get_student(student_id).get('project_id')
357
timeout : 60
358
cb : cb
359
], cb)
360
id = @set_activity(desc:"Creating #{students.length} student projects (do not close this until done)")
361
async.mapLimit student_ids, PARALLEL_LIMIT, f, (err) =>
362
@set_activity(id:id)
363
if err
364
@set_error("error creating student projects -- #{err}")
365
# after adding students, always run configure all projects,
366
# to ensure everything is set properly
367
@configure_all_projects()
368
369
delete_student: (student) =>
370
store = @get_store()
371
return if not store?
372
student = store.get_student(student)
373
@redux.getActions('projects').clear_project_upgrades(student.get('project_id'))
374
@_set
375
deleted : true
376
student_id : student.get('student_id')
377
table : 'students'
378
@configure_all_projects() # since they may get removed from shared project, etc.
379
380
undelete_student: (student) =>
381
store = @get_store()
382
return if not store?
383
student = store.get_student(student)
384
@_set
385
deleted : false
386
student_id : student.get('student_id')
387
table : 'students'
388
@configure_all_projects() # since they may get added back to shared project, etc.
389
390
# Some students might *only* have been added using their email address, but they
391
# subsequently signed up for an CoCalc account. We check for any of these and if
392
# we find any, we add in the account_id information about that student.
393
lookup_nonregistered_students: =>
394
store = @get_store()
395
if not store?
396
console.warn("lookup_nonregistered_students: store not initialized")
397
return
398
v = {}
399
s = []
400
store.get_students().map (student, student_id) =>
401
if not student.get('account_id') and not student.get('deleted')
402
email = student.get('email_address')
403
v[email] = student_id
404
s.push(email)
405
if s.length > 0
406
webapp_client.user_search
407
query : s.join(',')
408
limit : s.length
409
cb : (err, result) =>
410
if err
411
console.warn("lookup_nonregistered_students: search error -- #{err}")
412
else
413
for x in result
414
@_set
415
account_id : x.account_id
416
table : 'students'
417
student_id : v[x.email_address]
418
419
# columns: first_name ,last_name, email, last_active, hosting
420
# Toggles ascending/decending order
421
set_active_student_sort: (column_name) =>
422
store = @get_store()
423
if not store?
424
return
425
current_column = store.getIn(['active_student_sort', 'column_name'])
426
if current_column == column_name
427
is_descending = not store.getIn(['active_student_sort', 'is_descending'])
428
else
429
is_descending = false
430
@setState(active_student_sort : {column_name, is_descending})
431
432
set_internal_student_info: (student, info) =>
433
store = @get_store()
434
return if not store?
435
student = store.get_student(student)
436
437
info = defaults info,
438
first_name : required
439
last_name : required
440
email_address : student.get('email_address')
441
442
@_set
443
first_name : info.first_name
444
last_name : info.last_name
445
email_address : info.email_address
446
student_id : student.get('student_id')
447
table : 'students'
448
@configure_all_projects() # since they may get removed from shared project, etc.
449
450
451
# Student projects
452
453
# Create a single student project.
454
create_student_project: (student) =>
455
store = @get_store()
456
return if not store?
457
if not store.get('students')? or not store.get('settings')?
458
@set_error("attempt to create when stores not yet initialized")
459
return
460
if not @_create_student_project_queue?
461
@_create_student_project_queue = [student]
462
else
463
@_create_student_project_queue.push(student)
464
if not @_creating_student_project
465
@_process_create_student_project_queue()
466
467
# Process first requested student project creation action, then each subsequent one until
468
# there aren't any more to do.
469
_process_create_student_project_queue: () =>
470
@_creating_student_project = true
471
queue = @_create_student_project_queue
472
student = queue[0]
473
store = @get_store()
474
return if not store?
475
student_id = store.get_student(student).get('student_id')
476
@_set
477
create_project : webapp_client.server_time()
478
table : 'students'
479
student_id : student_id
480
id = @set_activity(desc:"Create project for #{store.get_student_name(student_id)}.")
481
token = misc.uuid()
482
@redux.getActions('projects').create_project
483
title : store.getIn(['settings', 'title'])
484
description : store.getIn(['settings', 'description'])
485
token : token
486
@redux.getStore('projects').wait_until_project_created token, 30, (err, project_id) =>
487
@clear_activity(id)
488
if err
489
@set_error("error creating student project for #{store.get_student_name(student_id)} -- #{err}")
490
else
491
@_set
492
create_project : null
493
project_id : project_id
494
table : 'students'
495
student_id : student_id
496
@configure_project(student_id, undefined, project_id)
497
delete @_creating_student_project
498
queue.shift()
499
if queue.length > 0
500
# do next one
501
@_process_create_student_project_queue()
502
503
configure_project_users: (student_project_id, student_id, do_not_invite_student_by_email) =>
504
#console.log("configure_project_users", student_project_id, student_id)
505
# Add student and all collaborators on this project to the project with given project_id.
506
# users = who is currently a user of the student's project?
507
users = @redux.getStore('projects').get_users(student_project_id) # immutable.js map
508
if not users?
509
# can't do anything if this isn't known...
510
return
511
# Define function to invite or add collaborator
512
s = @get_store()
513
if not s?
514
return
515
body = s.get_email_invite()
516
invite = (x) =>
517
account_store = @redux.getStore('account')
518
name = account_store.get_fullname()
519
replyto = account_store.get_email_address()
520
if '@' in x
521
if not do_not_invite_student_by_email
522
title = s.getIn(['settings', 'title'])
523
subject = "SageMathCloud Invitation to Course #{title}"
524
body = body.replace(/{title}/g, title).replace(/{name}/g, name)
525
body = markdownlib.markdown_to_html(body).s
526
@redux.getActions('projects').invite_collaborators_by_email(student_project_id, x, body, subject, true, replyto, name)
527
else
528
@redux.getActions('projects').invite_collaborator(student_project_id, x)
529
# Make sure the student is on the student's project:
530
student = s.get_student(student_id)
531
student_account_id = student.get('account_id')
532
if not student_account_id? # no known account yet
533
invite(student.get('email_address'))
534
else if not users?.get(student_account_id)? # users might not be set yet if project *just* created
535
invite(student_account_id)
536
# Make sure all collaborators on course project are on the student's project:
537
target_users = @redux.getStore('projects').get_users(s.get('course_project_id'))
538
if not target_users?
539
return # projects store isn't sufficiently initialized, so we can't do this yet...
540
target_users.map (_, account_id) =>
541
if not users.get(account_id)?
542
invite(account_id)
543
if not s.get_allow_collabs()
544
# Remove anybody extra on the student project
545
users.map (_, account_id) =>
546
if not target_users.get(account_id)? and account_id != student_account_id
547
@redux.getActions('projects').remove_collaborator(student_project_id, account_id)
548
549
configure_project_visibility: (student_project_id) =>
550
users_of_student_project = @redux.getStore('projects').get_users(student_project_id)
551
if not users_of_student_project? # e.g., not defined in admin view mode
552
return
553
# Make project not visible to any collaborator on the course project.
554
users = @redux.getStore('projects').get_users(@get_store().get('course_project_id'))
555
if not users? # TODO: should really wait until users is defined, which is a supported thing to do on stores!
556
return
557
users.map (_, account_id) =>
558
x = users_of_student_project.get(account_id)
559
if x? and not x.get('hide')
560
@redux.getActions('projects').set_project_hide(account_id, student_project_id, true)
561
562
configure_project_title: (student_project_id, student_id) =>
563
store = @get_store()
564
if not store?
565
return
566
title = "#{store.get_student_name(student_id)} - #{store.getIn(['settings', 'title'])}"
567
@redux.getActions('projects').set_project_title(student_project_id, title)
568
569
# start projects of all (non-deleted) students running
570
action_all_student_projects: (action) =>
571
if action not in ['start', 'stop', 'restart']
572
throw Error("action must be start, stop or restart")
573
@action_shared_project(action)
574
575
# Returns undefined if no store.
576
act_on_student_projects = () =>
577
return @get_store()?.get_students()
578
.filter (student) =>
579
not student.get('deleted') and student.get('project_id')?
580
.map (student) =>
581
@redux.getActions('projects')[action+"_project"](student.get('project_id'))
582
if not act_on_student_projects()
583
return
584
585
if @prev_interval_id?
586
window.clearInterval(@prev_interval_id)
587
if @prev_timeout_id?
588
window.clearTimeout(@prev_timeout_id)
589
590
clear_state = () =>
591
window.clearInterval(@prev_interval_id)
592
@setState(action_all_projects_state : "any")
593
594
@prev_interval_id = window.setInterval(act_on_student_projects, 30000)
595
@prev_timeout_id = window.setTimeout(clear_state, 300000) # 5 minutes
596
597
if action in ['start', 'restart']
598
@setState(action_all_projects_state : "starting")
599
else if action == 'stop'
600
@setState(action_all_projects_state : "stopping")
601
602
set_all_student_project_titles: (title) =>
603
actions = @redux.getActions('projects')
604
@get_store()?.get_students().map (student, student_id) =>
605
student_project_id = student.get('project_id')
606
project_title = "#{@get_store().get_student_name(student_id)} - #{title}"
607
if student_project_id?
608
actions.set_project_title(student_project_id, project_title)
609
610
configure_project_description: (student_project_id, student_id) =>
611
@redux.getActions('projects').set_project_description(student_project_id, @get_store()?.getIn(['settings', 'description']))
612
613
set_all_student_project_descriptions: (description) =>
614
@get_store()?.get_students().map (student, student_id) =>
615
student_project_id = student.get('project_id')
616
if student_project_id?
617
@redux.getActions('projects').set_project_description(student_project_id, description)
618
619
set_all_student_project_course_info: (pay) =>
620
store = @get_store()
621
if not store?
622
return
623
if not pay?
624
pay = store.get_pay()
625
else
626
@_set
627
pay : pay
628
table : 'settings'
629
store.get_students().map (student, student_id) =>
630
student_project_id = student.get('project_id')
631
# account_id: might not be known when student first added, or if student
632
# hasn't joined smc yet so there is no id.
633
student_account_id = student.get('account_id')
634
student_email_address = student.get('email_address') # will be known if account_id isn't known.
635
if student_project_id?
636
@redux.getActions('projects').set_project_course_info(student_project_id,
637
store.get('course_project_id'), store.get('course_filename'), pay, student_account_id, student_email_address)
638
639
configure_project: (student_id, do_not_invite_student_by_email, student_project_id) =>
640
# student_project_id is optional. Will be used instead of from student_id store if provided.
641
# Configure project for the given student so that it has the right title,
642
# description, and collaborators for belonging to the indicated student.
643
# - Add student and collaborators on project containing this course to the new project.
644
# - Hide project from owner/collabs of the project containing the course.
645
# - Set the title to [Student name] + [course title] and description to course description.
646
store = @get_store()
647
return if not store?
648
student_project_id = student_project_id ? store.getIn(['students', student_id, 'project_id'])
649
if not student_project_id?
650
@create_student_project(student_id)
651
else
652
@configure_project_users(student_project_id, student_id, do_not_invite_student_by_email)
653
@configure_project_visibility(student_project_id)
654
@configure_project_title(student_project_id, student_id)
655
@configure_project_description(student_project_id, student_id)
656
657
delete_project: (student_id) =>
658
store = @get_store()
659
return if not store?
660
student_project_id = store.getIn(['students', student_id, 'project_id'])
661
if student_project_id?
662
@redux.getActions('projects').delete_project(student_project_id)
663
@_set
664
create_project : null
665
project_id : null
666
table : 'students'
667
student_id : student_id
668
669
configure_all_projects: =>
670
id = @set_activity(desc:"Configuring all projects")
671
@setState(configure_projects:'Configuring projects')
672
store = @get_store()
673
if not store?
674
@set_activity(id:id)
675
return
676
for student_id in store.get_student_ids(deleted:false)
677
@configure_project(student_id, false) # always re-invite students on running this.
678
@configure_shared_project()
679
@set_activity(id:id)
680
@set_all_student_project_course_info()
681
682
delete_all_student_projects: () =>
683
id = @set_activity(desc:"Deleting all student projects...")
684
store = @get_store()
685
if not store?
686
@set_activity(id:id)
687
return
688
for student_id in store.get_student_ids(deleted:false)
689
@delete_project(student_id)
690
@set_activity(id:id)
691
692
# upgrade_goal is a map from the quota type to the goal quota the instructor wishes
693
# to get all the students to.
694
upgrade_all_student_projects: (upgrade_goal) =>
695
store = @get_store()
696
if not store?
697
return
698
plan = store.get_upgrade_plan(upgrade_goal)
699
if misc.len(plan) == 0
700
# nothing to do
701
return
702
id = @set_activity(desc:"Adjusting upgrades on #{misc.len(plan)} student projects...")
703
for project_id, upgrades of plan
704
@redux.getActions('projects').apply_upgrades_to_project(project_id, upgrades, false)
705
setTimeout((=>@set_activity(id:id)), 5000)
706
707
# Do an admin upgrade to all student projects. This changes the base quotas for every student
708
# project as indicated by the quotas object. E.g., to increase the core quota from 1 to 2, do
709
# .admin_upgrade_all_student_projects(cores:2)
710
# The quotas are: cores, cpu_shares, disk_quota, memory, mintime, network, member_host
711
admin_upgrade_all_student_projects: (quotas) =>
712
if not @redux.getStore('account').get('groups')?.contains('admin')
713
console.warn("must be an admin to upgrade")
714
return
715
store = @get_store()
716
if not store?
717
console.warn('unable to get store')
718
return
719
f = (project_id, cb) =>
720
x = misc.copy(quotas)
721
x.project_id = project_id
722
x.cb = (err, mesg) =>
723
if err or mesg.event == 'error'
724
console.warn("failed to set quotas for #{project_id} -- #{misc.to_json(mesg)}")
725
else
726
console.log("set quotas for #{project_id}")
727
cb(err)
728
webapp_client.project_set_quotas(x)
729
async.mapSeries store.get_student_project_ids(), f, (err) =>
730
if err
731
console.warn("FAIL -- #{err}")
732
else
733
console.log("SUCCESS")
734
735
set_student_note: (student, note) =>
736
store = @get_store()
737
return if not store?
738
student = store.get_student(student)
739
@_set
740
note : note
741
table : 'students'
742
student_id : student.get('student_id')
743
744
_collect_path: (path) =>
745
store = @get_store()
746
i = store.get('course_filename').lastIndexOf('.')
747
store.get('course_filename').slice(0,i) + '-collect/' + path
748
749
# Assignments
750
# TODO: Make a batch adder?
751
add_assignment: (path) =>
752
# Add an assignment to the course, which is defined by giving a directory in the project.
753
# Where we collect homework that students have done (in teacher project)
754
collect_path = @_collect_path(path)
755
path_parts = misc.path_split(path)
756
# folder that we return graded homework to (in student project)
757
if path_parts.head
758
beginning = '/graded-'
759
else
760
beginning = 'graded-'
761
graded_path = path_parts.head + beginning + path_parts.tail
762
# folder where we copy the assignment to
763
target_path = path
764
765
@_set
766
path : path
767
collect_path : collect_path
768
graded_path : graded_path
769
target_path : target_path
770
table : 'assignments'
771
assignment_id : misc.uuid()
772
773
delete_assignment: (assignment) =>
774
store = @get_store()
775
return if not store?
776
assignment = store.get_assignment(assignment)
777
@_set
778
deleted : true
779
assignment_id : assignment.get('assignment_id')
780
table : 'assignments'
781
782
undelete_assignment: (assignment) =>
783
store = @get_store()
784
return if not store?
785
assignment = store.get_assignment(assignment)
786
@_set
787
deleted : false
788
assignment_id : assignment.get('assignment_id')
789
table : 'assignments'
790
791
set_grade: (assignment, student, grade) =>
792
store = @get_store()
793
return if not store?
794
assignment = store.get_assignment(assignment)
795
student = store.get_student(student)
796
obj = {table:'assignments', assignment_id:assignment.get('assignment_id')}
797
grades = @_get_one(obj).grades ? {}
798
grades[student.get('student_id')] = grade
799
obj.grades = grades
800
@_set(obj)
801
802
set_active_assignment_sort: (column_name) =>
803
store = @get_store()
804
if not store?
805
return
806
current_column = store.getIn(['active_assignment_sort', 'column_name'])
807
if current_column == column_name
808
is_descending = not store.getIn(['active_assignment_sort', 'is_descending'])
809
else
810
is_descending = false
811
@setState(active_assignment_sort : {column_name, is_descending})
812
813
_set_assignment_field: (assignment, name, val) =>
814
store = @get_store()
815
return if not store?
816
assignment = store.get_assignment(assignment)
817
@_set
818
"#{name}" : val
819
table : 'assignments'
820
assignment_id : assignment.get('assignment_id')
821
822
set_due_date: (assignment, due_date) =>
823
if not typeof(due_date) == 'string'
824
due_date = due_date?.toISOString() # using strings instead of ms for backward compatibility.
825
@_set_assignment_field(assignment, 'due_date', due_date)
826
827
set_assignment_note: (assignment, note) =>
828
@_set_assignment_field(assignment, 'note', note)
829
830
set_peer_grade: (assignment, config) =>
831
cur = assignment.get('peer_grade')?.toJS() ? {}
832
for k, v of config
833
cur[k] = v
834
@_set_assignment_field(assignment, 'peer_grade', cur)
835
836
# Synchronous function that makes the peer grading map for the given
837
# assignment, if it hasn't already been made.
838
update_peer_assignment: (assignment) =>
839
store = @get_store()
840
return if not store?
841
assignment = store.get_assignment(assignment)
842
if assignment.getIn(['peer_grade', 'map'])?
843
return # nothing to do
844
N = assignment.getIn(['peer_grade','number']) ? 1
845
map = misc.peer_grading(store.get_student_ids(), N)
846
@set_peer_grade(assignment, map:map)
847
848
# Copy the files for the given assignment_id from the given student to the
849
# corresponding collection folder.
850
# If the store is initialized and the student and assignment both exist,
851
# then calling this action will result in this getting set in the store:
852
#
853
# assignment.last_collect[student_id] = {time:?, error:err}
854
#
855
# where time >= now is the current time in milliseconds.
856
copy_assignment_from_student: (assignment, student) =>
857
if @_start_copy(assignment, student, 'last_collect')
858
return
859
id = @set_activity(desc:"Copying assignment from a student")
860
finish = (err) =>
861
@clear_activity(id)
862
@_finish_copy(assignment, student, 'last_collect', err)
863
if err
864
@set_error("copy from student: #{err}")
865
store = @get_store()
866
return if not store?
867
if not @_store_is_initialized()
868
return finish("store not yet initialized")
869
if not student = store.get_student(student)
870
return finish("no student")
871
if not assignment = store.get_assignment(assignment)
872
return finish("no assignment")
873
student_name = store.get_student_name(student)
874
student_project_id = student.get('project_id')
875
if not student_project_id?
876
# nothing to do
877
@clear_activity(id)
878
else
879
target_path = assignment.get('collect_path') + '/' + student.get('student_id')
880
@set_activity(id:id, desc:"Copying assignment from #{student_name}")
881
async.series([
882
(cb) =>
883
webapp_client.copy_path_between_projects
884
src_project_id : student_project_id
885
src_path : assignment.get('target_path')
886
target_project_id : store.get('course_project_id')
887
target_path : target_path
888
overwrite_newer : true
889
backup : true
890
delete_missing : false
891
exclude_history : false
892
cb : cb
893
(cb) =>
894
# write their name to a file
895
name = store.get_student_name(student, true)
896
webapp_client.write_text_file_to_project
897
project_id : store.get('course_project_id')
898
path : target_path + "/STUDENT - #{name.simple}.txt"
899
content : "This student is #{name.full}."
900
cb : cb
901
], finish)
902
903
# Copy the graded files for the given assignment_id back to the student in a -graded folder.
904
# If the store is initialized and the student and assignment both exist,
905
# then calling this action will result in this getting set in the store:
906
#
907
# assignment.last_return_graded[student_id] = {time:?, error:err}
908
#
909
# where time >= now is the current time in milliseconds.
910
911
return_assignment_to_student: (assignment, student) =>
912
if @_start_copy(assignment, student, 'last_return_graded')
913
return
914
id = @set_activity(desc:"Returning assignment to a student")
915
finish = (err) =>
916
@clear_activity(id)
917
@_finish_copy(assignment, student, 'last_return_graded', err)
918
if err
919
@set_error("return to student: #{err}")
920
store = @get_store()
921
if not store? or not @_store_is_initialized()
922
return finish("store not yet initialized")
923
grade = store.get_grade(assignment, student)
924
if not student = store.get_student(student)
925
return finish("no student")
926
if not assignment = store.get_assignment(assignment)
927
return finish("no assignment")
928
student_name = store.get_student_name(student)
929
student_project_id = student.get('project_id')
930
if not student_project_id?
931
# nothing to do
932
@clear_activity(id)
933
else
934
@set_activity(id:id, desc:"Returning assignment to #{student_name}")
935
src_path = assignment.get('collect_path')
936
if assignment.getIn(['peer_grade', 'enabled'])
937
peer_graded = true
938
src_path += '-peer-grade/'
939
else
940
peer_graded = false
941
src_path += '/' + student.get('student_id')
942
async.series([
943
(cb) =>
944
# write their grade to a file
945
content = "Your grade on this assignment:\n\n #{grade}"
946
if peer_graded
947
content += "\n\n\nPEER GRADED:\n\nYour assignment was peer graded by other students.\nYou can find the comments they made in the folders below."
948
webapp_client.write_text_file_to_project
949
project_id : store.get('course_project_id')
950
path : src_path + '/GRADE.txt'
951
content : content
952
cb : cb
953
(cb) =>
954
webapp_client.copy_path_between_projects
955
src_project_id : store.get('course_project_id')
956
src_path : src_path
957
target_project_id : student_project_id
958
target_path : assignment.get('graded_path')
959
overwrite_newer : true
960
backup : true
961
delete_missing : false
962
exclude_history : true
963
cb : cb
964
(cb) =>
965
if peer_graded
966
# Delete GRADER file
967
webapp_client.exec
968
project_id : student_project_id
969
command : 'rm ./*/GRADER*.txt'
970
timeout : 60
971
bash : true
972
path : assignment.get('graded_path')
973
cb : cb
974
else
975
cb(null)
976
], finish)
977
978
# Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
979
return_assignment_to_all_students: (assignment, new_only) =>
980
id = @set_activity(desc:"Returning assignments to all students #{if new_only then 'who have not already received it' else ''}")
981
error = (err) =>
982
@clear_activity(id)
983
@set_error("return to student: #{err}")
984
store = @get_store()
985
if not store? or not @_store_is_initialized()
986
return error("store not yet initialized")
987
if not assignment = store.get_assignment(assignment) # correct use of "=" sign!
988
return error("no assignment")
989
errors = ''
990
peer = assignment.get('peer_grade')?.get('enabled')
991
f = (student_id, cb) =>
992
if not store.last_copied(previous_step('return_graded', peer), assignment, student_id, true)
993
# we never collected the assignment from this student
994
cb(); return
995
if not store.has_grade(assignment, student_id)
996
# we collected but didn't grade it yet
997
cb(); return
998
if new_only
999
if store.last_copied('return_graded', assignment, student_id, true) and store.has_grade(assignment, student_id)
1000
# it was already returned
1001
cb(); return
1002
n = misc.mswalltime()
1003
@return_assignment_to_student(assignment, student_id)
1004
store.wait
1005
timeout : 60*15
1006
until : => store.last_copied('return_graded', assignment, student_id) >= n
1007
cb : (err) =>
1008
if err
1009
errors += "\n #{err}"
1010
cb()
1011
async.mapLimit store.get_student_ids(deleted:false), PARALLEL_LIMIT, f, (err) =>
1012
if errors
1013
error(errors)
1014
else
1015
@clear_activity(id)
1016
1017
_finish_copy: (assignment, student, type, err) =>
1018
if student? and assignment?
1019
store = @get_store()
1020
if not store?
1021
return
1022
student = store.get_student(student)
1023
assignment = store.get_assignment(assignment)
1024
obj = {table:'assignments', assignment_id:assignment.get('assignment_id')}
1025
x = @_get_one(obj)?[type] ? {}
1026
student_id = student.get('student_id')
1027
x[student_id] = {time: misc.mswalltime()}
1028
if err
1029
x[student_id].error = err
1030
obj[type] = x
1031
@_set(obj)
1032
1033
# This is called internally before doing any copy/collection operation
1034
# to ensure that we aren't doing the same thing repeatedly, and that
1035
# everything is in place to do the operation.
1036
_start_copy: (assignment, student, type) =>
1037
if student? and assignment?
1038
store = @get_store()
1039
if not store?
1040
return
1041
student = store.get_student(student)
1042
assignment = store.get_assignment(assignment)
1043
obj = {table:'assignments', assignment_id:assignment.get('assignment_id')}
1044
x = @_get_one(obj)?[type] ? {}
1045
y = (x[student.get('student_id')]) ? {}
1046
if y.start? and webapp_client.server_time() - y.start <= 15000
1047
return true # never retry a copy until at least 15 seconds later.
1048
y.start = misc.mswalltime()
1049
x[student.get('student_id')] = y
1050
obj[type] = x
1051
@_set(obj)
1052
return false
1053
1054
_stop_copy: (assignment, student, type) =>
1055
if student? and assignment?
1056
store = @get_store()
1057
if not store?
1058
return
1059
student = store.get_student(student)
1060
assignment = store.get_assignment(assignment)
1061
obj = {table:'assignments', assignment_id:assignment.get('assignment_id')}
1062
x = @_get_one(obj)?[type]
1063
if not x?
1064
return
1065
y = (x[student.get('student_id')])
1066
if not y?
1067
return
1068
if y.start?
1069
delete y.start
1070
x[student.get('student_id')] = y
1071
obj[type] = x
1072
@_set(obj)
1073
1074
# Copy the files for the given assignment to the given student. If
1075
# the student project doesn't exist yet, it will be created.
1076
# You may also pass in an id for either the assignment or student.
1077
# If the store is initialized and the student and assignment both exist,
1078
# then calling this action will result in this getting set in the store:
1079
#
1080
# assignment.last_assignment[student_id] = {time:?, error:err}
1081
#
1082
# where time >= now is the current time in milliseconds.
1083
copy_assignment_to_student: (assignment, student) =>
1084
if @_start_copy(assignment, student, 'last_assignment')
1085
return
1086
id = @set_activity(desc:"Copying assignment to a student")
1087
finish = (err) =>
1088
@clear_activity(id)
1089
@_finish_copy(assignment, student, 'last_assignment', err)
1090
if err
1091
@set_error("copy to student: #{err}")
1092
store = @get_store()
1093
if not store? or not @_store_is_initialized()
1094
return finish("store not yet initialized")
1095
if not student = store.get_student(student)
1096
return finish("no student")
1097
if not assignment = store.get_assignment(assignment)
1098
return finish("no assignment")
1099
1100
student_name = store.get_student_name(student)
1101
@set_activity(id:id, desc:"Copying assignment to #{student_name}")
1102
student_project_id = student.get('project_id')
1103
student_id = student.get('student_id')
1104
src_path = assignment.get('path')
1105
async.series([
1106
(cb) =>
1107
if not student_project_id?
1108
@set_activity(id:id, desc:"#{student_name}'s project doesn't exist, so creating it.")
1109
@create_student_project(student)
1110
store = @get_store()
1111
if not store?
1112
cb("no store")
1113
return
1114
store.wait
1115
until : => store.get_student_project_id(student_id)
1116
cb : (err, x) =>
1117
student_project_id = x
1118
cb(err)
1119
else
1120
cb()
1121
(cb) =>
1122
# write the due date to a file
1123
due_date = store.get_due_date(assignment)
1124
if not due_date?
1125
cb(); return
1126
webapp_client.write_text_file_to_project
1127
project_id : store.get('course_project_id')
1128
path : src_path + '/DUE_DATE.txt'
1129
content : "This assignment is due\n\n #{due_date.toLocaleString()}"
1130
cb : cb
1131
(cb) =>
1132
@set_activity(id:id, desc:"Copying files to #{student_name}'s project")
1133
webapp_client.copy_path_between_projects
1134
src_project_id : store.get('course_project_id')
1135
src_path : src_path
1136
target_project_id : student_project_id
1137
target_path : assignment.get('target_path')
1138
overwrite_newer : false
1139
delete_missing : false
1140
backup : true
1141
exclude_history : true
1142
cb : cb
1143
], (err) =>
1144
finish(err)
1145
)
1146
1147
1148
1149
copy_assignment: (type, assignment_id, student_id) =>
1150
# type = assigned, collected, graded
1151
switch type
1152
when 'assigned'
1153
@copy_assignment_to_student(assignment_id, student_id)
1154
when 'collected'
1155
@copy_assignment_from_student(assignment_id, student_id)
1156
when 'graded'
1157
@return_assignment_to_student(assignment_id, student_id)
1158
when 'peer-assigned'
1159
@peer_copy_to_student(assignment_id, student_id)
1160
when 'peer-collected'
1161
@peer_collect_from_student(assignment_id, student_id)
1162
else
1163
@set_error("copy_assignment -- unknown type: #{type}")
1164
1165
# Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
1166
copy_assignment_to_all_students: (assignment, new_only) =>
1167
desc = "Copying assignments to all students #{if new_only then 'who have not already received it' else ''}"
1168
short_desc = "copy to student"
1169
@_action_all_students(assignment, new_only, @copy_assignment_to_student, 'assignment', desc, short_desc)
1170
1171
# Copy the given assignment to all non-deleted students, doing several copies in parallel at once.
1172
copy_assignment_from_all_students: (assignment, new_only) =>
1173
desc = "Copying assignment from all students #{if new_only then 'from whom we have not already copied it' else ''}"
1174
short_desc = "copy from student"
1175
@_action_all_students(assignment, new_only, @copy_assignment_from_student, 'collect', desc, short_desc)
1176
1177
peer_copy_to_all_students: (assignment, new_only) =>
1178
desc = "Copying assignments for peer grading to all students #{if new_only then 'who have not already received their copy' else ''}"
1179
short_desc = "copy to student for peer grading"
1180
@_action_all_students(assignment, new_only, @peer_copy_to_student, 'peer_assignment', desc, short_desc)
1181
1182
peer_collect_from_all_students: (assignment, new_only) =>
1183
desc = "Copying peer graded assignments from all students #{if new_only then 'from whom we have not already copied it' else ''}"
1184
short_desc = "copy peer grading from students"
1185
@_action_all_students(assignment, new_only, @peer_collect_from_student, 'peer_collect', desc, short_desc)
1186
1187
_action_all_students: (assignment, new_only, action, step, desc, short_desc) =>
1188
id = @set_activity(desc:desc)
1189
error = (err) =>
1190
@clear_activity(id)
1191
err="#{short_desc}: #{err}"
1192
@set_error(err)
1193
store = @get_store()
1194
if not store? or not @_store_is_initialized()
1195
return error("store not yet initialized")
1196
if not assignment = store.get_assignment(assignment)
1197
return error("no assignment")
1198
errors = ''
1199
peer = assignment.get('peer_grade')?.get('enabled')
1200
prev_step = previous_step(step, peer)
1201
f = (student_id, cb) =>
1202
if prev_step? and not store.last_copied(prev_step, assignment, student_id, true)
1203
cb(); return
1204
if new_only and store.last_copied(step, assignment, student_id, true)
1205
cb(); return
1206
n = misc.mswalltime()
1207
action(assignment, student_id)
1208
store.wait
1209
timeout : 60*15
1210
until : => store.last_copied(step, assignment, student_id) >= n
1211
cb : (err) =>
1212
if err
1213
errors += "\n #{err}"
1214
cb()
1215
1216
async.mapLimit store.get_student_ids(deleted:false), PARALLEL_LIMIT, f, (err) =>
1217
if errors
1218
error(errors)
1219
else
1220
@clear_activity(id)
1221
1222
# Copy the collected folders from some students to the given student for peer grading.
1223
# Assumes folder is non-empty
1224
peer_copy_to_student: (assignment, student) =>
1225
if @_start_copy(assignment, student, 'last_peer_assignment')
1226
return
1227
id = @set_activity(desc:"Copying peer grading to a student")
1228
finish = (err) =>
1229
@clear_activity(id)
1230
@_finish_copy(assignment, student, 'last_peer_assignment', err)
1231
if err
1232
@set_error("copy peer-grading to student: #{err}")
1233
store = @get_store()
1234
if not store? or not @_store_is_initialized()
1235
return finish("store not yet initialized")
1236
if not student = store.get_student(student)
1237
return finish("no student")
1238
if not assignment = store.get_assignment(assignment)
1239
return finish("no assignment")
1240
1241
student_name = store.get_student_name(student)
1242
@set_activity(id:id, desc:"Copying peer grading to #{student_name}")
1243
1244
@update_peer_assignment(assignment) # synchronous
1245
1246
# list of student_id's
1247
peers = store.get_peers_that_student_will_grade(assignment, student)
1248
if not peers?
1249
# empty peer assignment for this student (maybe added late)
1250
return finish()
1251
1252
student_project_id = student.get('project_id')
1253
1254
guidelines = assignment.getIn(['peer_grade', 'guidelines']) ? 'Please grade this assignment.'
1255
due_date = assignment.getIn(['peer_grade', 'due_date'])
1256
if due_date?
1257
guidelines = "GRADING IS DUE #{new Date(due_date).toLocaleString()} \n\n " + guidelines
1258
1259
target_base_path = assignment.get('path') + "-peer-grade"
1260
f = (student_id, cb) =>
1261
src_path = assignment.get('collect_path') + '/' + student_id
1262
target_path = target_base_path + "/" + student_id
1263
async.series([
1264
(cb) =>
1265
# delete the student's name so that grading is anonymous; also, remove original
1266
# due date to avoid confusion.
1267
name = store.get_student_name(student_id, true)
1268
webapp_client.exec
1269
project_id : store.get('course_project_id')
1270
command : 'rm'
1271
args : ['-f', src_path + "/STUDENT - #{name.simple}.txt", src_path + "/DUE_DATE.txt", src_path + "/STUDENT - #{name.simple}.txt~", src_path + "/DUE_DATE.txt~"]
1272
cb : cb
1273
(cb) =>
1274
# copy the files to be peer graded into place for this student
1275
webapp_client.copy_path_between_projects
1276
src_project_id : store.get('course_project_id')
1277
src_path : src_path
1278
target_project_id : student_project_id
1279
target_path : target_path
1280
overwrite_newer : false
1281
delete_missing : false
1282
cb : cb
1283
], cb)
1284
1285
# write instructions file to the student
1286
webapp_client.write_text_file_to_project
1287
project_id : student_project_id
1288
path : target_base_path + "/GRADING_GUIDE.md"
1289
content : guidelines
1290
cb : (err) =>
1291
if not err
1292
# now copy actual stuff to grade
1293
async.map(peers, f, finish)
1294
else
1295
finish(err)
1296
1297
# Collect all the peer graading of the given student (not the work the student did, but
1298
# the grading about the student!).
1299
peer_collect_from_student: (assignment, student) =>
1300
if @_start_copy(assignment, student, 'last_peer_collect')
1301
return
1302
id = @set_activity(desc:"Collecting peer grading of a student")
1303
finish = (err) =>
1304
@clear_activity(id)
1305
@_finish_copy(assignment, student, 'last_peer_collect', err)
1306
if err
1307
@set_error("collecting peer-grading of a student: #{err}")
1308
store = @get_store()
1309
if not store? or not @_store_is_initialized()
1310
return finish("store not yet initialized")
1311
if not student = store.get_student(student)
1312
return finish("no student")
1313
if not assignment = store.get_assignment(assignment)
1314
return finish("no assignment")
1315
1316
student_name = store.get_student_name(student)
1317
@set_activity(id:id, desc:"Collecting peer grading of #{student_name}")
1318
1319
# list of student_id of students that graded this student
1320
peers = store.get_peers_that_graded_student(assignment, student)
1321
if not peers?
1322
# empty peer assignment for this student (maybe added late)
1323
return finish()
1324
1325
our_student_id = student.get('student_id')
1326
1327
f = (student_id, cb) =>
1328
s = store.get_student(student_id)
1329
if s.get('deleted')
1330
# ignore deleted students
1331
cb()
1332
return
1333
path = assignment.get('path')
1334
src_path = "#{path}-peer-grade/#{our_student_id}"
1335
target_path = "#{assignment.get('collect_path')}-peer-grade/#{our_student_id}/#{student_id}"
1336
async.series([
1337
(cb) =>
1338
# copy the files over from the student who did the peer grading
1339
webapp_client.copy_path_between_projects
1340
src_project_id : s.get('project_id')
1341
src_path : src_path
1342
target_project_id : store.get('course_project_id')
1343
target_path : target_path
1344
overwrite_newer : false
1345
delete_missing : false
1346
cb : cb
1347
(cb) =>
1348
# write local file identifying the grader
1349
name = store.get_student_name(student_id, true)
1350
webapp_client.write_text_file_to_project
1351
project_id : store.get('course_project_id')
1352
path : target_path + "/GRADER - #{name.simple}.txt"
1353
content : "The student who did the peer grading is named #{name.full}."
1354
cb : cb
1355
(cb) =>
1356
# write local file identifying student being graded
1357
name = store.get_student_name(student, true)
1358
webapp_client.write_text_file_to_project
1359
project_id : store.get('course_project_id')
1360
path : target_path + "/STUDENT - #{name.simple}.txt"
1361
content : "This student is #{name.full}."
1362
cb : cb
1363
], cb)
1364
1365
async.map(peers, f, finish)
1366
1367
# This doesn't really stop it yet, since that's not supported by the backend.
1368
# It does stop the spinner and let the user try to restart the copy.
1369
stop_copying_assignment: (type, assignment_id, student_id) =>
1370
switch type
1371
when 'assigned'
1372
type = 'last_assignment'
1373
when 'collected'
1374
type = 'last_collect'
1375
when 'graded'
1376
type = 'last_return_graded'
1377
when 'peer-assigned'
1378
type = 'last_peer_assignment'
1379
when 'peer-collected'
1380
type = 'last_peer_collect'
1381
@_stop_copy(assignment_id, student_id, type)
1382
1383
open_assignment: (type, assignment_id, student_id) =>
1384
# type = assigned, collected, graded
1385
store = @get_store()
1386
if not store?
1387
return
1388
assignment = store.get_assignment(assignment_id)
1389
student = store.get_student(student_id)
1390
student_project_id = student.get('project_id')
1391
if not student_project_id?
1392
@set_error("open_assignment: student project not yet created")
1393
return
1394
# Figure out what to open
1395
switch type
1396
when 'assigned' # where project was copied in the student's project.
1397
path = assignment.get('target_path')
1398
proj = student_project_id
1399
when 'collected' # where collected locally
1400
path = assignment.get('collect_path') + '/' + student.get('student_id') # TODO: refactor
1401
proj = store.get('course_project_id')
1402
when 'peer-assigned' # where peer-assigned (in student's project)
1403
proj = student_project_id
1404
path = assignment.get('path') + '-peer-grade'
1405
when 'peer-collected' # where collected peer-graded work (in our project)
1406
path = assignment.get('collect_path') + '-peer-grade/' + student.get('student_id')
1407
proj = store.get('course_project_id')
1408
when 'graded' # where project returned
1409
path = assignment.get('graded_path') # refactor
1410
proj = student_project_id
1411
else
1412
@set_error("open_assignment -- unknown type: #{type}")
1413
if not proj?
1414
@set_error("no such project")
1415
return
1416
# Now open it
1417
@redux.getProjectActions(proj).open_directory(path)
1418
1419
# Handouts
1420
add_handout: (path) =>
1421
target_path = path # folder where we copy the handout to
1422
@_set
1423
path : path
1424
target_path : target_path
1425
table : 'handouts'
1426
handout_id : misc.uuid()
1427
1428
delete_handout: (handout) =>
1429
store = @get_store()
1430
return if not store?
1431
handout = store.get_handout(handout)
1432
@_set
1433
deleted : true
1434
handout_id : handout.get('handout_id')
1435
table : 'handouts'
1436
1437
undelete_handout: (handout) =>
1438
store = @get_store()
1439
return if not store?
1440
handout = store.get_handout(handout)
1441
@_set
1442
deleted : false
1443
handout_id : handout.get('handout_id')
1444
table : 'handouts'
1445
1446
_set_handout_field: (handout, name, val) =>
1447
store = @get_store()
1448
return if not store?
1449
handout = store.get_handout(handout)
1450
@_set
1451
"#{name}" : val
1452
table : 'handouts'
1453
handout_id : handout.get('handout_id')
1454
1455
set_handout_note: (handout, note) =>
1456
@_set_handout_field(handout, 'note', note)
1457
1458
_handout_finish_copy: (handout, student, err) =>
1459
if student? and handout?
1460
store = @get_store()
1461
if not store?
1462
return
1463
student = store.get_student(student)
1464
handout = store.get_handout(handout)
1465
obj = {table:'handouts', handout_id:handout.get('handout_id')}
1466
status_map = @_get_one(obj)?.status ? {}
1467
student_id = student.get('student_id')
1468
status_map[student_id] = {time: misc.mswalltime()}
1469
if err
1470
status_map[student_id].error = err
1471
obj.status = status_map
1472
@_set(obj)
1473
1474
_handout_start_copy: (handout, student) =>
1475
if student? and handout?
1476
store = @get_store()
1477
if not store?
1478
return
1479
student = store.get_student(student)
1480
handout = store.get_handout(handout)
1481
obj = {table:'handouts', handout_id:handout.get('handout_id')}
1482
status_map = @_get_one(obj)?.status ? {}
1483
student_status = (status_map[student.get('student_id')]) ? {}
1484
if student_status.start? and webapp_client.server_time() - student_status.start <= 15000
1485
return true # never retry a copy until at least 15 seconds later.
1486
student_status.start = misc.mswalltime()
1487
status_map[student.get('student_id')] = student_status
1488
obj.status = status_map
1489
@_set(obj)
1490
return false
1491
1492
# "Copy" of `stop_copying_assignment:`
1493
stop_copying_handout: (handout, student) =>
1494
if student? and handout?
1495
store = @get_store()
1496
if not store?
1497
return
1498
student = store.get_student(student)
1499
handout = store.get_handout(handout)
1500
obj = {table:'handouts', handout_id:handout.get('handout_id')}
1501
status = @_get_one(obj)?.status
1502
if not status?
1503
return
1504
student_status = (status[student.get('student_id')])
1505
if not student_status?
1506
return
1507
if student_status.start?
1508
delete student_status.start
1509
status[student.get('student_id')] = student_status
1510
obj.status = status
1511
@_set(obj)
1512
1513
# Copy the files for the given handout to the given student. If
1514
# the student project doesn't exist yet, it will be created.
1515
# You may also pass in an id for either the handout or student.
1516
# If the store is initialized and the student and handout both exist,
1517
# then calling this action will result in this getting set in the store:
1518
#
1519
# handout.status[student_id] = {time:?, error:err}
1520
#
1521
# where time >= now is the current time in milliseconds.
1522
copy_handout_to_student: (handout, student) =>
1523
if @_handout_start_copy(handout, student)
1524
return
1525
id = @set_activity(desc:"Copying handout to a student")
1526
finish = (err) =>
1527
@clear_activity(id)
1528
@_handout_finish_copy(handout, student, err)
1529
if err
1530
@set_error("copy to student: #{err}")
1531
store = @get_store()
1532
if not store? or not @_store_is_initialized()
1533
return finish("store not yet initialized")
1534
if not student = store.get_student(student)
1535
return finish("no student")
1536
if not handout = store.get_handout(handout)
1537
return finish("no handout")
1538
1539
student_name = store.get_student_name(student)
1540
@set_activity(id:id, desc:"Copying handout to #{student_name}")
1541
student_project_id = student.get('project_id')
1542
student_id = student.get('student_id')
1543
src_path = handout.get('path')
1544
async.series([
1545
(cb) =>
1546
if not student_project_id?
1547
@set_activity(id:id, desc:"#{student_name}'s project doesn't exist, so creating it.")
1548
@create_student_project(student)
1549
store = @get_store()
1550
if not store?
1551
cb("no store")
1552
return
1553
store.wait
1554
until : => store.get_student_project_id(student_id)
1555
cb : (err, x) =>
1556
student_project_id = x
1557
cb(err)
1558
else
1559
cb()
1560
(cb) =>
1561
@set_activity(id:id, desc:"Copying files to #{student_name}'s project")
1562
webapp_client.copy_path_between_projects
1563
src_project_id : store.get('course_project_id')
1564
src_path : src_path
1565
target_project_id : student_project_id
1566
target_path : handout.get('target_path')
1567
overwrite_newer : false
1568
delete_missing : false
1569
backup : true
1570
exclude_history : true
1571
cb : cb
1572
], (err) =>
1573
finish(err)
1574
)
1575
1576
# Copy the given handout to all non-deleted students, doing several copies in parallel at once.
1577
copy_handout_to_all_students: (handout, new_only) =>
1578
desc = "Copying handouts to all students #{if new_only then 'who have not already received it' else ''}"
1579
short_desc = "copy to student"
1580
1581
id = @set_activity(desc:desc)
1582
error = (err) =>
1583
@clear_activity(id)
1584
err="#{short_desc}: #{err}"
1585
@set_error(err)
1586
store = @get_store()
1587
if not store? or not @_store_is_initialized()
1588
return error("store not yet initialized")
1589
if not handout = store.get_handout(handout)
1590
return error("no handout")
1591
errors = ''
1592
f = (student_id, cb) =>
1593
if new_only and store.handout_last_copied(handout, student_id, true)
1594
cb(); return
1595
n = misc.mswalltime()
1596
@copy_handout_to_student(handout, student_id)
1597
store.wait
1598
timeout : 60*15
1599
until : => store.handout_last_copied(handout, student_id) >= n
1600
cb : (err) =>
1601
if err
1602
errors += "\n #{err}"
1603
cb()
1604
1605
async.mapLimit store.get_student_ids(deleted:false), PARALLEL_LIMIT, f, (err) =>
1606
if errors
1607
error(errors)
1608
else
1609
@clear_activity(id)
1610
1611
open_handout: (handout_id, student_id) =>
1612
store = @get_store()
1613
if not store?
1614
return
1615
handout = store.get_handout(handout_id)
1616
student = store.get_student(student_id)
1617
student_project_id = student.get('project_id')
1618
if not student_project_id?
1619
@set_error("open_handout: student project not yet created")
1620
return
1621
path = handout.get('target_path')
1622
proj = student_project_id
1623
if not proj?
1624
@set_error("no such project")
1625
return
1626
# Now open it
1627
@redux.getProjectActions(proj).open_directory(path)
1628
1629