| Name | Total Lines | Lines of Code | Total Coverage | Code Coverage |
|---|---|---|---|---|
| app/models/grouping.rb | 675 | 484 | 98.81%
|
98.55%
|
Code reported as executed by Ruby looks like this...and this: this line is also marked as covered.Lines considered as run by rcov, but not reported by Ruby, look like this,and this: these lines were inferred by rcov (using simple heuristics).Finally, here's a line marked as not executed.
1 # we need repository permission constants |
2 require 'repo/repository' |
3 |
4 # Represents a collection of students working together on an assignment in a group |
5 class Grouping < ActiveRecord::Base |
6 |
7 before_create :create_grouping_repository_folder |
8 before_destroy :revoke_repository_permissions_for_students |
9 belongs_to :assignment, :counter_cache => true |
10 belongs_to :group |
11 belongs_to :grouping_queue |
12 has_many :memberships |
13 has_many :student_memberships, :order => 'id' |
14 has_many :non_rejected_student_memberships, |
15 :class_name => "StudentMembership", |
16 :conditions => ['memberships.membership_status != ?', |
17 StudentMembership::STATUSES[:rejected]] |
18 has_many :accepted_student_memberships, |
19 :class_name => "StudentMembership", |
20 :conditions => { |
21 'memberships.membership_status' => [ |
22 StudentMembership::STATUSES[:accepted], |
23 StudentMembership::STATUSES[:inviter]]} |
24 has_many :notes, :as => :noteable, :dependent => :destroy |
25 has_many :ta_memberships, :class_name => "TaMembership" |
26 has_many :tas, :through => :ta_memberships, :source => :user |
27 has_many :students, :through => :student_memberships, :source => :user |
28 has_many :pending_students, |
29 :class_name => 'Student', |
30 :through => :student_memberships, |
31 :conditions => { |
32 'memberships.membership_status' => StudentMembership::STATUSES[:pending]}, |
33 :source => :user |
34 |
35 has_many :submissions |
36 #The first submission found that satisfies submission_version_used == true. |
37 #If there are multiple such submissions, one is chosen randomly. |
38 has_one :current_submission_used, |
39 :class_name => 'Submission', |
40 :conditions => {:submission_version_used => true} |
41 has_many :grace_period_deductions, |
42 :through => :non_rejected_student_memberships |
43 |
44 has_one :token |
45 |
46 scope :approved_groupings, :conditions => {:admin_approved => true} |
47 |
48 validates_numericality_of :criteria_coverage_count, :greater_than_or_equal_to => 0 |
49 |
50 # user association/validation |
51 validates_presence_of :assignment_id |
52 validates_associated :assignment, :on => :create, :message => "associated assignment need to be valid" |
53 |
54 validates_presence_of :group_id |
55 validates_associated :group, :message => "associated group need to be valid" |
56 |
57 validates_inclusion_of :is_collected, :in => [true, false] |
58 |
59 def accepted_students |
60 accepted_students = self.accepted_student_memberships.collect do |memb| |
61 memb.user |
62 end |
63 return accepted_students |
64 end |
65 |
66 def group_name_with_student_user_names |
67 student_user_names = student_memberships.collect {|m| m.user.user_name } |
68 return group.group_name if student_user_names.size == 0 |
69 return group.group_name + ": " + student_user_names.join(', ') |
70 end |
71 |
72 def display_for_note |
73 return assignment.short_identifier + ": " + group_name_with_student_user_names |
74 end |
75 |
76 # Query Functions ------------------------------------------------------ |
77 |
78 # Returns whether or not a TA is assigned to mark this Grouping |
79 def has_ta_for_marking? |
80 return ta_memberships.count > 0 |
81 end |
82 |
83 #Returns whether or not the submission_collector is pending to collect this |
84 #grouping's newest submission |
85 def is_collected? |
86 return is_collected |
87 end |
88 |
89 # Returns an array of the user_names for any TA's assigned to mark |
90 # this Grouping |
91 def get_ta_names |
92 return ta_memberships.collect do |membership| |
93 membership.user.user_name |
94 end |
95 end |
96 |
97 # Returns the member with 'inviter' status for this group |
98 def inviter |
99 member = student_memberships.find_by_membership_status(StudentMembership::STATUSES[:inviter]) |
100 if member.nil? |
101 return nil |
102 end |
103 inviting_student = Student.find(member.user_id) |
104 return inviting_student |
105 end |
106 |
107 |
108 # Returns true if this user has a pending status for this group; |
109 # false otherwise, or if user is not in this group. |
110 def pending?(user) |
111 return membership_status(user) == StudentMembership::STATUSES[:pending] |
112 end |
113 |
114 # returns whether the user is the inviter of this group or not. |
115 def is_inviter?(user) |
116 return membership_status(user) == StudentMembership::STATUSES[:inviter] |
117 end |
118 |
119 # invites each user in 'members' by its user name, to this group |
120 # If the method is invoked by an admin, checks on whether the students can |
121 # be part of the group are skipped. |
122 def invite(members, |
123 set_membership_status=StudentMembership::STATUSES[:pending], |
124 invoked_by_admin=false) |
125 # overloading invite() to accept members arg as both a string and a array |
126 members = [members] if !members.instance_of?(Array) # put a string in an |
127 # array |
128 members.each do |m| |
129 next if m.blank? # ignore blank users |
130 m = m.strip |
131 user = User.find_by_user_name(m) |
132 m_logger = MarkusLogger.instance |
133 if !user |
134 errors.add(:base, I18n.t('invite_student.fail.dne', |
135 :user_name => m)) |
136 else |
137 if invoked_by_admin || self.can_invite?(user) |
138 member = self.add_member(user, set_membership_status) |
139 if !member |
140 errors.add(:base, I18n.t('invite_student.fail.error', |
141 :user_name => user.user_name)) |
142 m_logger.log("Student failed to invite '#{user.user_name}'", |
143 MarkusLogger::ERROR) |
144 else |
145 m_logger.log("Student invited '#{user.user_name}'.") |
146 end |
147 end |
148 end |
149 end |
150 end |
151 |
152 # Add a new member to base |
153 def add_member(user, set_membership_status=StudentMembership::STATUSES[:accepted]) |
154 if user.has_accepted_grouping_for?(self.assignment_id) || user.hidden |
155 return nil |
156 else |
157 member = StudentMembership.new(:user => user, :membership_status => |
158 set_membership_status, :grouping => self) |
159 member.save |
160 # adjust repo permissions |
161 update_repository_permissions |
162 return member |
163 end |
164 end |
165 |
166 # define whether user can be invited in this grouping |
167 def can_invite?(user) |
168 m_logger = MarkusLogger.instance |
169 if user && user.student? |
170 if user.hidden |
171 errors.add(:base, I18n.t('invite_student.fail.hidden', |
172 :user_name => user.user_name)) |
173 m_logger.log("Student failed to invite '#{user.user_name}' (account has been " + |
174 "disabled).", MarkusLogger::ERROR) |
175 |
176 return false |
177 end |
178 if self.inviter == user |
179 errors.add(:base, I18n.t('invite_student.fail.inviting_self', |
180 :user_name => user.user_name)) |
181 m_logger.log("Student failed to invite '#{user.user_name}'. Tried to invite " + |
182 "himself.", MarkusLogger::ERROR) |
183 |
184 |
185 end |
186 if self.assignment.past_collection_date? |
187 errors.add(:base, I18n.t('invite_student.fail.due_date_passed', |
188 :user_name => user.user_name)) |
189 m_logger.log("Student failed to invite '#{user.user_name}'. Current time past " + |
190 "collection date.", MarkusLogger::ERROR) |
191 |
192 return false |
193 end |
194 if self.student_membership_number >= self.assignment.group_max |
195 errors.add(:base, I18n.t('invite_student.fail.group_max_reached', |
196 :user_name => user.user_name)) |
197 m_logger.log("Student failed to invite '#{user.user_name}'. Group maximum" + |
198 " reached.", MarkusLogger::ERROR) |
199 return false |
200 end |
201 if self.assignment.section_groups_only && |
202 user.section != self.inviter.section |
203 errors.add(:base, I18n.t('invite_student.fail.not_same_section', |
204 :user_name => user.user_name)) |
205 m_logger.log("Student failed to invite '#{user.user_name}'. Students not in" + |
206 " same section.", MarkusLogger::ERROR) |
207 |
208 return false |
209 end |
210 if user.has_accepted_grouping_for?(self.assignment.id) |
211 errors.add(:base, I18n.t('invite_student.fail.already_grouped', |
212 :user_name => user.user_name)) |
213 m_logger.log("Student failed to invite '#{user.user_name}'. Invitee already part" + |
214 " of another group.", MarkusLogger::ERROR) |
215 return false |
216 end |
217 if self.pending?(user) |
218 errors.add(:base, I18n.t('invite_student.fail.already_pending', |
219 :user_name => user.user_name)) |
220 m_logger.log("Student failed to invite '#{user.user_name}'. Invitee is already " + |
221 " pending member of this group.", MarkusLogger::ERROR) |
222 return false |
223 end |
224 else |
225 errors.add(:base, I18n.t('invite_student.fail.dne', |
226 :user_name => user.user_name)) |
227 m_logger.log("Student failed to invite '#{user.user_name}'. Invitee does not " + |
228 " exist.", MarkusLogger::ERROR) |
229 return false |
230 end |
231 return true |
232 end |
233 |
234 # Returns the status of this user, or nil if user is not a member |
235 def membership_status(user) |
236 member = student_memberships.find_by_user_id(user.id) |
237 member ? member.membership_status : nil # return nil if user is not a member |
238 end |
239 |
240 # returns the numbers of memberships, all includ (inviter, pending, |
241 # accepted |
242 def student_membership_number |
243 return accepted_students.size + pending_students.size |
244 end |
245 |
246 # Returns true if either this Grouping has met the assignment group |
247 # size minimum, OR has been approved by an instructor |
248 def is_valid? |
249 return admin_approved || (non_rejected_student_memberships.size >= assignment.group_min) |
250 end |
251 |
252 # Validates a group |
253 def validate_grouping |
254 self.admin_approved = true |
255 self.save |
256 # update repository permissions |
257 update_repository_permissions |
258 end |
259 |
260 # Strips admin_approved privledge |
261 def invalidate_grouping |
262 self.admin_approved = false |
263 self.save |
264 # update repository permissions |
265 update_repository_permissions |
266 end |
267 |
268 # Token Credit Query |
269 def give_tokens |
270 Token.create(:grouping_id => self.id, :tokens => self.assignment.tokens_per_day) if self.assignment.enable_test |
271 end |
272 |
273 # Grace Credit Query |
274 def available_grace_credits |
275 total = [] |
276 accepted_students.each do |student| |
277 total.push(student.remaining_grace_credits) |
278 end |
279 return total.min |
280 end |
281 |
282 def grace_period_deduction_sum |
283 total = 0 |
284 grace_period_deductions.each do |grace_period_deduction| |
285 total += grace_period_deduction.deduction |
286 end |
287 return total |
288 end |
289 |
290 # Submission Functions |
291 def has_submission? |
292 #Return true if and only if this grouping has at least one submission |
293 #with attribute submission_version_used == true. |
294 return !current_submission_used.nil? |
295 end |
296 |
297 def marking_completed? |
298 return has_submission? && current_submission_used.result.marking_state == Result::MARKING_STATES[:complete] |
299 end |
300 |
301 # EDIT METHODS |
302 # Removes the member by its membership id |
303 def remove_member(mbr_id) |
304 member = student_memberships.find(mbr_id) |
305 if member |
306 # Remove repository permissions first |
307 # Corner case: members are removed by admins only. |
308 # Hence, we do not require to check for validity of the group |
309 revoke_repository_permissions_for_membership(member) |
310 member.destroy |
311 if member.membership_status == StudentMembership::STATUSES[:inviter] |
312 if member.grouping.accepted_student_memberships.length > 0 |
313 membership = member.grouping.accepted_student_memberships.first |
314 membership.membership_status = StudentMembership::STATUSES[:inviter] |
315 membership.save |
316 end |
317 end |
318 end |
319 end |
320 |
321 def delete_grouping |
322 self.student_memberships.all(:include => :user).each do |member| |
323 member.destroy |
324 end |
325 # adjust repository permissions |
326 update_repository_permissions |
327 self.destroy |
328 end |
329 |
330 # Removes the member rejected by its membership id |
331 # Used as safeguard when student deletes the record |
332 def remove_rejected(mbr_id) |
333 member = memberships.find(mbr_id) |
334 member.destroy if member && member.membership_status == StudentMembership::STATUSES[:rejected] |
335 end |
336 |
337 def decline_invitation(student) |
338 membership = student.memberships.find_by_grouping_id(self.id) |
339 membership.membership_status = StudentMembership::STATUSES[:rejected] |
340 membership.save |
341 # adjust repo permissions |
342 update_repository_permissions |
343 end |
344 |
345 # If a group is invalid OR valid and the user is the inviter of the group and |
346 # she is the _only_ member of this grouping it should be deletable |
347 # by this user, provided there haven't been any files submitted. Additionally, |
348 # the grace period for the assignment should not have passed. |
349 def deletable_by?(user) |
350 return false unless self.inviter == user |
351 return (!self.is_valid?) || (self.is_valid? && |
352 self.accepted_students.size == 1 && |
353 self.number_of_submitted_files == 0 && |
354 self.assignment.group_assignment? && |
355 !assignment.past_collection_date? ) |
356 end |
357 |
358 # Returns the number of files submitted by this grouping for a |
359 # particular assignment. |
360 def number_of_submitted_files |
361 path = '/' |
362 repo = self.group.repo |
363 rev = repo.get_latest_revision |
364 files = rev.files_at_path(File.join(File.join(self.assignment.repository_folder, path))) |
365 repo.close() |
366 return files.keys.length |
367 end |
368 |
369 # Returns last modified date of the assignment_folder in this grouping's repository |
370 def assignment_folder_last_modified_date |
371 repo = self.group.repo |
372 rev = repo.get_latest_revision |
373 # get the full path of repository folder |
374 path = self.assignment.repository_folder |
375 |
376 # split "repo_folder_path" into two parts |
377 parent_path = File.dirname(path) |
378 folder_name = File.basename(path) |
379 |
380 # turn "parent_path" into absolute path |
381 parent_path = repo.expand_path(parent_path, "/") |
382 last_date = rev.directories_at_path(parent_path)[folder_name].last_modified_date |
383 repo.close() |
384 return last_date |
385 end |
386 |
387 # Returns a list of missing assignment_files yet to be submitted |
388 def missing_assignment_files |
389 missing_assignment_files = [] |
390 self.group.access_repo do |repo| |
391 rev = repo.get_latest_revision |
392 assignment = self.assignment |
393 assignment.assignment_files.each do |assignment_file| |
394 if !rev.path_exists?(File.join(assignment.repository_folder, assignment_file.filename)) |
395 missing_assignment_files.push(assignment_file) |
396 end |
397 end |
398 end |
399 return missing_assignment_files |
400 end |
401 |
402 def add_tas(tas) |
403 #this check was previously done every time a ta_membership was created, |
404 #however since the assignment is the same, validating it once for every new |
405 #membership is a huge waste, so validate once and only proceed if true. |
406 return unless self.assignment.valid? |
407 grouping_tas = self.tas |
408 tas = Array(tas) |
409 tas.each do |ta| |
410 if !grouping_tas.include? ta |
411 #due to the membership's validates_associated :grouping attribute, only |
412 #call its validation for the first grader as the grouping is constant |
413 #and all the tas are ensured to be valid in the add_graders action in |
414 #graders_controller |
415 if ta == tas.first |
416 #perform validation first time. |
417 ta_memberships.create(:user => ta) |
418 else |
419 #skip validation to increase performance (all aspects of validation |
420 #have already been performed elsewhere) |
421 member = ta_memberships.build(:user => ta) |
422 member.save(:validate => false) |
423 end |
424 grouping_tas += [ta] |
425 end |
426 end |
427 criteria = self.all_assigned_criteria(grouping_tas | tas) |
428 self.criteria_coverage_count = criteria.length |
429 if self.criteria_coverage_count >= 0 |
430 #skip validation on save. grouping already gets validated on creation of |
431 #ta_membership. Ensure criteria_coverage_count >= 0 as this is the only |
432 #attribute that gets changed between the validation above and the save |
433 #below. This is done to improve performance, as any validations of the |
434 #grouping result in 5 extra database queries |
435 self.save(:validate => false) |
436 end |
437 end |
438 |
439 def remove_tas(ta_id_array) |
440 #if no tas to remove, return. |
441 return if ta_id_array == [] |
442 ta_memberships_to_remove = ta_memberships.find_all_by_user_id(ta_id_array, :include => :user) |
443 ta_memberships_to_remove.each do |ta_membership| |
444 ta_membership.destroy |
445 ta_memberships.delete(ta_membership) |
446 end |
447 criteria = self.all_assigned_criteria(self.tas - ta_memberships_to_remove.collect{|mem| mem.user}) |
448 self.criteria_coverage_count = criteria.length |
449 self.save |
450 end |
451 |
452 def add_tas_by_user_name_array(ta_user_name_array) |
453 grouping_tas = [] |
454 ta_user_name_array.each do |ta_user_name| |
455 ta = Ta.find_by_user_name(ta_user_name) |
456 if !ta.nil? |
457 if ta_memberships.find_by_user_id(ta.id).nil? |
458 ta_memberships.create(:user => ta) |
459 end |
460 end |
461 grouping_tas += Array(ta) |
462 end |
463 self.criteria_coverage_count = self.all_assigned_criteria(grouping_tas).length |
464 self.save |
465 end |
466 |
467 # Returns an array containing the group names that didn't exist |
468 def self.assign_tas_by_csv(csv_file_contents, assignment_id) |
469 failures = [] |
470 FasterCSV.parse(csv_file_contents) do |row| |
471 group_name = row.shift # Knocks the first item from array |
472 group = Group.find_by_group_name(group_name) |
473 if group.nil? |
474 failures.push(group_name) |
475 else |
476 grouping = group.grouping_for_assignment(assignment_id) |
477 if grouping.nil? |
478 failures.push(group_name) |
479 else |
480 grouping.add_tas_by_user_name_array(row) # The rest of the array |
481 end |
482 end |
483 end |
484 return failures |
485 end |
486 |
487 # Update repository permissions for students, if we allow external commits |
488 # see: grant_repository_permissions and revoke_repository_permissions |
489 def update_repository_permissions |
490 # we do not need to do anything if we are not accepting external |
491 # command-line commits |
492 return unless self.write_repo_permissions? |
493 |
494 self.reload # VERY IMPORTANT! Make sure grouping object is not stale |
495 |
496 if self.is_valid? |
497 grant_repository_permissions |
498 else |
499 # grouping became invalid, remove repo permissions |
500 revoke_repository_permissions |
501 end |
502 end |
503 |
504 # When a Grouping is created, automatically create the folder for the |
505 # assignment in the repository, if it doesn't already exist. |
506 def create_grouping_repository_folder |
507 |
508 # create folder only if we are repo admin |
509 if self.group.repository_admin? |
510 self.group.access_repo do |repo| |
511 revision = repo.get_latest_revision |
512 assignment_folder = File.join('/', assignment.repository_folder) |
513 |
514 if revision.path_exists?(assignment_folder) |
515 return true |
516 else |
517 txn = self.group.repo.get_transaction("markus") |
518 txn.add_path(assignment_folder) |
519 return self.group.repo.commit(txn) |
520 end |
521 end |
522 end |
523 end |
524 |
525 # Returns true, if and only if the configured repository setup |
526 # allows for externally accessible repositories, in which case |
527 # file submissions via the Web interface are not permitted. For |
528 # now, this works for Subversion repositories only. |
529 def repository_external_commits_only? |
530 assignment = self.assignment |
531 return !assignment.allow_web_submits |
532 end |
533 |
534 # Should we write repository permissions for this grouping? |
535 def write_repo_permissions? |
536 return MarkusConfigurator.markus_config_repository_admin? && |
537 self.repository_external_commits_only? |
538 end |
539 |
540 def assigned_tas_for_criterion(criterion) |
541 result = [] |
542 if assignment.assign_graders_to_criteria |
543 tas.each do |ta| |
544 if ta.criterion_ta_associations.find_by_criterion_id(criterion.id) |
545 result.push(ta) |
546 end |
547 end |
548 end |
549 return result |
550 end |
551 |
552 def all_assigned_criteria(ta_array) |
553 result = [] |
554 if assignment.assign_graders_to_criteria |
555 ta_array.each do |ta| |
556 result = result.concat(ta.get_criterion_associations_by_assignment(assignment)) |
557 end |
558 end |
559 return result.map{|a| a.criterion}.uniq |
560 end |
561 |
562 # Get the section for this group. If assignment restricts member of a groupe |
563 # to a section, all students are in the same section. Therefore, return only |
564 # the inviters section |
565 def section |
566 if !self.inviter.nil? and self.inviter.has_section? |
567 return self.inviter.section.name |
568 end |
569 return '-' |
570 end |
571 |
572 private |
573 |
574 # Once a grouping is valid, grant (write) repository permissions for students |
575 # who have accepted memberships (including the inviter) |
576 # |
577 # precondition: grouping is valid, self.reload has been called |
578 def grant_repository_permissions |
579 memberships = self.accepted_student_memberships |
580 if !memberships.instance_of?(Array) |
581 memberships = [memberships] |
582 end |
583 memberships.each do |member| |
584 # Add repository read and write permissions for user, |
585 # if we are required to do so |
586 if self.write_repo_permissions? |
587 begin |
588 self.group.access_repo do |repo| |
589 repo.add_user(member.user.user_name, Repository::Permission::READ_WRITE) |
590 end |
591 rescue Repository::UserAlreadyExistent |
592 # ignore case if user has permissions already |
593 end |
594 end |
595 end |
596 end |
597 |
598 # We need to revoke repository permissions for student users in certain cases. |
599 # |
600 # For instance if the inviter has invited 2 students for a total of 3 students in |
601 # that group, which in turn is the required group minimum. In that case, students |
602 # who have accepted their membership, would have gotten repo permissions granted. |
603 # But once one of the 2 invited students declines to be member of that group, the group |
604 # becomes invalid (is below the group minimum of 3 people), and, hence, granted |
605 # repo permissions for student users need to be revoked again. |
606 # |
607 # precondition: grouping is invalid, self.reload has been called |
608 def revoke_repository_permissions |
609 memberships = self.accepted_student_memberships |
610 if !memberships.instance_of?(Array) |
611 memberships = [memberships] |
612 end |
613 memberships.each do |member| |
614 # Revoke permissions for students |
615 if self.write_repo_permissions? |
616 self.group.access_repo do |repo| |
617 begin |
618 # the following throws a Repository::UserNotFound |
619 if repo.get_permissions(member.user.user_name) >= Repository::Permission::ANY |
620 # user has some permissions, we need to remove them |
621 repo.remove_user(member.user.user_name) |
622 end |
623 rescue Repository::UserNotFound |
624 # if student has no permissions, we are safe |
625 end |
626 end |
627 end |
628 end |
629 end |
630 |
631 # Removes repository permissions for a single StudentMembership object |
632 def revoke_repository_permissions_for_membership(student_membership) |
633 # Revoke permissions for student |
634 self.group.access_repo do |repo| |
635 if self.write_repo_permissions? |
636 begin |
637 # the following throws a Repository::UserNotFound |
638 if repo.get_permissions(student_membership.user.user_name) >= Repository::Permission::ANY |
639 # user has some permissions, we need to remove them |
640 repo.remove_user(student_membership.user.user_name) |
641 end |
642 rescue Repository::UserNotFound |
643 # if student has no permissions, we are safe |
644 end |
645 end |
646 end |
647 end |
648 |
649 # Removes any repository permissions of students for a to be destroyed |
650 # grouping object. see :before_destroy callback above |
651 def revoke_repository_permissions_for_students |
652 self.reload # avoid a stale object |
653 |
654 memberships = self.student_memberships # get any student memberships |
655 if !memberships.instance_of?(Array) |
656 memberships = [memberships] |
657 end |
658 memberships.each do |member| |
659 # Revoke permissions for students |
660 self.group.access_repo do |repo| |
661 if self.write_repo_permissions? |
662 begin |
663 # the following throws a Repository::UserNotFound |
664 if repo.get_permissions(member.user.user_name) >= Repository::Permission::ANY |
665 # user has some permissions, we need to remove them |
666 repo.remove_user(member.user.user_name) |
667 end |
668 rescue Repository::UserNotFound |
669 # if student has no permissions, we are safe |
670 end |
671 end |
672 end |
673 end |
674 end |
675 end # end class Grouping |
Generated on Sun Feb 05 00:08:07 -0500 2012 with rcov 0.9.10