• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MarkUsProject / Markus / 18783844054

24 Oct 2025 03:06PM UTC coverage: 91.574% (+0.06%) from 91.516%
18783844054

Pull #7697

github

web-flow
Merge d198c3871 into 22805cd2d
Pull Request #7697: Add scheduled visibility for assessments

787 of 1638 branches covered (48.05%)

Branch coverage included in aggregate %.

193 of 198 new or added lines in 10 files covered. (97.47%)

55 existing lines in 4 files now uncovered.

42751 of 45906 relevant lines covered (93.13%)

121.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.27
/app/lib/repository.rb
1
module Repository
2✔
2
  # Configuration for the repository library,
3
  # which is set via Repository.get_class
4
  # TODO: Get rid of Repository.conf
5
  # @CONF = {}
6
  #  def Repository.conf
7
  #   return @CONF
8
  #  end
9

10
  # Permission constants for repositories
11
  class Permission
2✔
12
    unless defined? WRITE  # avoid constant already defined warnings
2✔
13
      WRITE = 2
2✔
14
    end
15
    unless defined? READ
2✔
16
      READ = 4
2✔
17
    end
18
    unless defined? READ_WRITE
2✔
19
      READ_WRITE = READ + WRITE
2✔
20
    end
21
    unless defined? ANY
2✔
22
      ANY = READ # any permission means at least read permission
2✔
23
    end
24
  end
25

26
  ROOT_DIR = (Settings.file_storage.repos || File.join(Settings.file_storage.default_root_path, 'repos')).freeze
2✔
27
  PERMISSION_FILE = File.join(ROOT_DIR, '.access').freeze
2✔
28

29
  # Exceptions for repositories
30
  class ConnectionError < StandardError; end
2✔
31

32
  class Conflict < StandardError
2✔
33
    attr_reader :path
2✔
34

35
    def initialize(path)
2✔
36
      super()
9✔
37
      @path = path
9✔
38
    end
39

40
    def to_s
2✔
41
      "There was an unspecified conflict with file #{@path}"
×
42
    end
43
  end
44

45
  class FileExistsConflict < Conflict
2✔
46
    def to_s
2✔
47
      "#{@path} could not be added - it already exists in the folder. \
×
48
        If you'd like to overwrite, try replacing the file instead."
49
    end
50
  end
51

52
  class FileDoesNotExistConflict < Conflict
2✔
53
    def to_s
2✔
54
      "#{@path} could not be changed - it was deleted since you last saw it"
1✔
55
    end
56
  end
57

58
  # Exception for folders
59
  class FolderExistsConflict < Conflict
2✔
60
    def to_s
2✔
61
      "#{@path} could not be added - it already exists"
1✔
62
    end
63
  end
64

65
  class FolderDoesNotExistConflict < Conflict
2✔
66
    def to_s
2✔
67
      "#{@path} could not be removed - it is not exist"
1✔
68
    end
69
  end
70

71
  # Exception for folders
72
  class FolderIsNotEmptyConflict < Conflict
2✔
73
    def to_s
2✔
74
      "#{@path} could not be removed - it is not empty"
×
75
    end
76
  end
77

78
  class FileOutOfSyncConflict < Conflict
2✔
79
    def to_s
2✔
80
      "#{@path} has been updated since you last saw it, and could not be changed"
×
81
    end
82
  end
83

84
  class ExportRepositoryAlreadyExists < StandardError; end
2✔
85

86
  class RepositoryCollision < StandardError; end
2✔
87

88
  class AbstractRepository
2✔
89
    # Initializes Object, and verifies connection to the repository back end.
90
    # This should throw a ConnectionError if we're unable to connect.
91
    def initialize(connect_string)
2✔
92
      raise NotImplementedError
×
93
    end
94

95
    # Static method: Should report if a repository exists at given location
96
    def self.repository_exists?(path)
2✔
97
      raise NotImplementedError
×
98
    end
99

100
    # Static method: Opens a repository at given location; returns an
101
    # AbstractRepository instance
102
    def self.open(connect_string)
2✔
103
      raise NotImplementedError
×
104
    end
105

106
    # Static method: Creates a new repository at given location; returns
107
    # an AbstractRepository instance, with the repository opened.
108
    def self.create(connect_string, course)
2✔
109
      raise NotImplementedError
×
110
    end
111

112
    # Static method: Yields an existing Repository and closes it afterwards
113
    def self.access(connect_string)
2✔
114
      raise NotImplementedError
×
115
    end
116

117
    # Static method: Deletes an existing repository
118
    def self.delete(connect_string)
2✔
119
      raise NotImplementedError
×
120
    end
121

122
    # Closes the repository
123
    def close
2✔
124
      raise NotImplementedError
×
125
    end
126

127
    # Tests if the repository is closed
128
    def closed?
2✔
129
      raise NotImplementedError
×
130
    end
131

132
    # Static method: returns the shell command to check out a repository or one of its folders
133
    def self.get_checkout_command(external_repo_url, revision_identifier, group_name, repo_folder = nil)
2✔
134
      raise NotImplementedError
×
135
    end
136

137
    # Given either an array of, or a single object of class RevisionFile,
138
    # return a stream of data for the user to download as the file(s).
139
    def stringify_files(files)
2✔
140
      raise NotImplementedError
×
141
    end
142
    alias download_as_string stringify_files
2✔
143

144
    # Returns a transaction for the provided user and uses comment as the commit message
145
    def get_transaction(user_id, comment)
2✔
146
      raise NotImplementedError
×
147
    end
148

149
    # Commits a transaction associated with a repository
150
    def commit(transaction)
2✔
151
      raise NotImplementedError
×
152
    end
153

154
    # Returns the latest Repository::AbstractRevision
155
    def get_latest_revision
2✔
156
      raise NotImplementedError
×
157
    end
158

159
    # Returns all revisions
160
    def get_all_revisions
2✔
161
      raise NotImplementedError
×
162
    end
163

164
    # Return a Repository::AbstractRevision for a given revision_identifier
165
    # if it exists
166
    def get_revision(revision_identifier)
2✔
167
      raise NotImplementedError
×
168
    end
169

170
    # Return a RepositoryRevision for a given timestamp
171
    def get_revision_by_timestamp(at_or_earlier_than, path = nil, later_than = nil)
2✔
172
      raise NotImplementedError
×
173
    end
174

175
    # Converts a pathname to an absolute pathname
176
    def expand_path(file_name, dir_string)
2✔
177
      raise NotImplementedError
×
178
    end
179

180
    # This function allows a cached value of non_bare_repo to be cleared.
181
    # Currently only implemented in GitRepository.
182
    def reload_non_bare_repo
2✔
183
      raise NotImplementedError
×
184
    end
185

186
    # Updates permissions file unless it is being called from within a
187
    # block passed to self.update_permissions_after or if it does not
188
    # read the most up to date data (using self.get_all_permissions)
189
    def self.update_permissions
2✔
190
      return unless Settings.repository.is_repository_admin
11,148✔
191
      Thread.current[:requested?] = true
11,147✔
192
      # abort if this is being called in a block passed to
193
      # self.update_permissions_after
194
      return if Thread.current[:permissions_lock]&.owned?
11,147✔
195
      UpdateRepoPermissionsJob.perform_later(self.name)
11,126✔
196
      nil
197
    end
198

199
    # Executes a block of code and then updates the permissions file.
200
    # Also prevents any calls to self.update_permissions or
201
    # self.update_permissions_after within that block.
202
    #
203
    # If only_on_request is true then self.update_permissions will be
204
    # called after the block only if it would have been called in the
205
    # yielded block but was prevented
206
    #
207
    # This allows us to ensure that the permissions file will only be
208
    # updated a single time once all relevant changes have been made.
209
    def self.update_permissions_after(only_on_request: false, &block)
2✔
210
      if Thread.current[:permissions_lock].nil?
162✔
211
        Thread.current[:permissions_lock] = Mutex.new
1✔
212
        Thread.current[:requested?] = false
1✔
213
      end
214
      if Thread.current[:permissions_lock].owned?
162✔
215
        # if owned by the current thread, yield the block without
216
        # trying to lock again (which would raise a ThreadError)
217
        yield
3✔
218
      else
219
        Thread.current[:permissions_lock].synchronize(&block)
159✔
220
      end
221
      if !only_on_request || Thread.current[:requested?]
162✔
222
        self.update_permissions
159✔
223
      end
224
      nil
225
    end
226

227
    # Returns the assignments for which students have repository access.
228
    #
229
    # Repository authentication subtleties:
230
    # 1) a repository is associated with a Group, but..
231
    # 2) ..students are associated with a Grouping (an "instance" of Group for a specific Assignment)
232
    # That creates a problem since authentication in git is at the repository level, while Markus handles it at
233
    # the assignment level, allowing the same Group repo to have different students according to the assignment.
234
    # The two extremes to implement it are using the union of all students (permissive) or the intersection
235
    # (restrictive). Instead, we are going to take a last-deadline approach, where we assume that the valid students at
236
    # any point in time are the ones valid for the last assignment due. (Basically, it's nice for a group to share a
237
    # repo among assignments, but at a certain point during the course we may want to add or [more frequently] remove
238
    # some students from it)
239
    def self.get_repo_auth_records
2✔
240
      records = Assignment.joins(:assignment_properties, :course)
16✔
241
                          .includes(groupings: [:group, { accepted_students: :section }])
242
                          .where(assignment_properties: { vcs_submit: true }, 'courses.is_hidden': false)
243
                          .order(due_date: :desc)
244
      records.where(assignment_properties: { is_timed: false })
16✔
245
             .or(records.where.not(groupings: { start_time: nil }))
246
             .or(records.where(groupings: { start_time: nil }, due_date: Time.utc(0)..Time.current))
247
    end
248

249
    # Return a nested hash of the form { assignment_id => { section_id => visibility } } where visibility
250
    # is a boolean indicating whether the given assignment is visible to the given section.
251
    def self.visibility_hash
2✔
252
      current_time = Time.current
53✔
253
      records = Assignment.left_outer_joins(:assessment_section_properties)
53✔
254
                          .pluck_to_hash('assessments.id',
255
                                         'section_id',
256
                                         'assessments.is_hidden',
257
                                         'assessments.visible_on',
258
                                         'assessments.visible_until',
259
                                         'assessment_section_properties.is_hidden',
260
                                         'assessment_section_properties.visible_on',
261
                                         'assessment_section_properties.visible_until')
262

263
      visibilities = records.uniq { |r| r['assessments.id'] }
153✔
264
                            .map do |r|
265
                              # Check if datetime-based visibility is set
266
                              visible_on = r['assessments.visible_on']
92✔
267
                              visible_until = r['assessments.visible_until']
92✔
268
                              default_visible = if visible_on || visible_until
92✔
NEW
269
                                                  (visible_on.nil? || visible_on <= current_time) &&
×
NEW
270
                                                    (visible_until.nil? || visible_until >= current_time)
×
271
                                                else
272
                                                  !r['assessments.is_hidden']
92✔
273
                                                end
274
                              [r['assessments.id'], Hash.new { default_visible }]
138✔
275
                            end
276
                            .to_h
277

278
      records.each do |r|
53✔
279
        section_visible_on = r['assessment_section_properties.visible_on']
104✔
280
        section_visible_until = r['assessment_section_properties.visible_until']
104✔
281
        section_is_hidden = r['assessment_section_properties.is_hidden']
104✔
282

283
        unless section_is_hidden.nil? && section_visible_on.nil? && section_visible_until.nil?
104✔
284
          # Section-specific settings exist
285
          section_visible = if section_visible_on || section_visible_until
14✔
NEW
286
                              (section_visible_on.nil? || section_visible_on <= current_time) &&
×
NEW
287
                                (section_visible_until.nil? || section_visible_until >= current_time)
×
288
                            else
289
                              !section_is_hidden
14✔
290
                            end
291
          visibilities[r['assessments.id']][r['section_id']] = section_visible
14✔
292
        end
293
      end
294
      visibilities
53✔
295
    end
296

297
    # Builds a hash of all repositories and users allowed to access them (assumes all permissions are rw)
298
    def self.get_all_permissions
2✔
299
      visibility = self.visibility_hash
9✔
300
      permissions = Hash.new { |h, k| h[k] = [] }
15✔
301
      admins = AdminUser.pluck(:user_name)
9✔
302
      permissions['*/*'] = admins unless admins.empty?
9✔
303
      instructors = Instructor.joins(:course, :user)
9✔
304
                              .where('roles.hidden': false)
305
                              .pluck('courses.name', 'users.user_name')
306
                              .group_by(&:first)
307
                              .transform_values { |val| val.map(&:second) }
2✔
308
      instructors.each do |course_name, instructor_names|
9✔
309
        permissions[File.join(course_name, '*')] = instructor_names
2✔
310
      end
311
      self.get_repo_auth_records.each do |assignment|
9✔
312
        assignment.valid_groupings.each do |valid_grouping|
2✔
313
          next unless visibility[assignment.id][valid_grouping.inviter&.section&.id]
6✔
314
          repo_name = valid_grouping.group.repository_relative_path
6✔
315
          accepted_students = valid_grouping.accepted_students.where('roles.hidden': false).map(&:user_name)
6✔
316
          permissions[repo_name] = accepted_students
6✔
317
        end
318
      end
319
      # NOTE: this will allow graders to access the files in the entire repository
320
      # even if they are the grader for only a single assignment
321
      graders_info = TaMembership.joins(role: [:user, :course],
9✔
322
                                        grouping: [:group, { assignment: :assignment_properties }])
323
                                 .where('assignment_properties.anonymize_groups': false, 'roles.hidden': false)
324
                                 .pluck(:repo_name, :user_name, 'courses.name')
325
      graders_info.each do |repo_name, user_name, course_name|
9✔
326
        repo_path = File.join(course_name, repo_name) # NOTE: duplicates functionality of Group.repository_relative_path
3✔
327
        permissions[repo_path] << user_name
3✔
328
      end
329
      permissions
9✔
330
    end
331

332
    # '*' which is reserved to indicate all repos when setting permissions
333
    # TODO: add to this if needed
334
    def self.reserved_locations
2✔
335
      ['*']
3✔
336
    end
337

338
    # Generate and write the the authorization file for all repos.
339
    def self.update_permissions_file(_permissions)
2✔
340
      raise NotImplementedError
×
341
    end
342

343
    # Returns a set of file names that are used internally by the repository and are not part of any student submission.
344
    def self.internal_file_names
2✔
345
      []
464✔
346
    end
347

348
    # Exclusive blocking lock using a redis list to ensure that all threads and all processes respect
349
    # the lock.  If the resource defined by +resource_id+ is locked, the calling thread will wait +timeout+
350
    # milliseconds, while trying to acquire the lock every +interval+ milliseconds. If the calling thread
351
    # is able to acquire the lock it will yield, otherwise the passed block will not be executed
352
    # and a Timeout::Error will be raised.
353
    #
354
    # Access to the resource will be given in request order. So if threads a, b, and c all request access to the
355
    # same resource (in that order), access is guaranteed to be given to a then b then c (in that order).
356
    #
357
    # The +namespace+ argument can be given to ensure that two resources with the same resource_id can be treated
358
    # as separate resources as long as the +namespace+ value is distinct. By default the +namespace+ is the relative
359
    # root of the current MarkUs instance.
360
    def self.redis_exclusive_lock(resource_id, namespace: Rails.root.to_s, timeout: 5000, interval: 100)
2✔
361
      redis = Redis::Namespace.new(namespace, redis: Resque.redis)
7,597✔
362
      return yield if redis.lrange(resource_id, -1, -1).first&.to_i == Thread.current.object_id
7,597✔
363

364
      # clear any threads that are no longer alive from the queue
365
      redis.lrange(resource_id, 0, -1).each do |thread_id|
7,562✔
366
        begin
367
          thread_obj = ObjectSpace._id2ref(thread_id.to_i)
×
368
        rescue TypeError, RangeError
369
          redis.lrem(resource_id, 0, thread_id)
×
370
          next
×
371
        end
372
        unless thread_obj.is_a?(Thread) && thread_obj.alive?
×
373
          redis.lrem(resource_id, 0, thread_id)
×
374
        end
375
      end
376

377
      redis.lpush(resource_id, Thread.current.object_id) # assume thread ids are unique accross processes as well
7,562✔
378
      elapsed_time = 0
7,562✔
379
      begin
380
        loop do
7,562✔
381
          return yield if redis.lrange(resource_id, -1, -1).first&.to_i == Thread.current.object_id
7,562✔
382
          raise Timeout::Error, I18n.t('repo.timeout') if elapsed_time >= timeout
×
383

384
          sleep(interval / 1000.0) # interval is in milliseconds but sleep arg is in seconds
×
385
          elapsed_time += interval
×
386
        end
387
      ensure
388
        redis.lrem(resource_id, -1, Thread.current.object_id)
7,562✔
389
      end
390
    end
391

392
    # Given a subdirectory path, and an already created zip_file, fill the subdirectory
393
    # within the zip_file with all of its files.
394
    #
395
    # If a block is passed to this function, The block will receive a Repository::RevisionFile
396
    # object as a parameter.
397
    # The result of the block will be written to the zip file instead of the file content.
398
    #
399
    # This can be used to modify the file content before it is written to the zip file.
400
    def send_tree_to_zip(subdirectory_path, zip_file, revision, zip_subdir: nil, &block)
2✔
401
      revision.tree_at_path(subdirectory_path, with_attrs: false).each do |path, obj|
40✔
402
        if obj.is_a? Repository::RevisionFile
44✔
403
          file_contents = block ? yield(obj) : download_as_string(obj)
44✔
404
          full_path = zip_subdir ? File.join(zip_subdir, path) : path
44✔
405
          zip_file.get_output_stream(full_path) do |f|
44✔
406
            f.print file_contents
44✔
407
          end
408
        end
409
      end
410
    end
411
  end
412

413
  # Exceptions for Revisions
414
  class RevisionDoesNotExist < StandardError; end
2✔
415
  class RevisionOutOfSyncConflict < Conflict; end
2✔
416

417
  class AbstractRevision
2✔
418
    attr_reader :revision_identifier, :revision_identifier_ui, :timestamp, :user_id, :comment
2✔
419
    attr_accessor :server_timestamp
2✔
420

421
    def initialize(revision_identifier)
2✔
422
      raise RevisionDoesNotExist if revision_identifier.nil?
13,480✔
423

424
      @revision_identifier = revision_identifier
13,480✔
425
      @revision_identifier_ui = @revision_identifier
13,480✔
426
    end
427

428
    # Checks if +path+ is a file or directory in this revision of the repository.
429
    def path_exists?(path)
2✔
430
      raise NotImplementedError
×
431
    end
432

433
    # Checks if there are changes under +path+ (subdirectories included) due to this revision.
434
    def changes_at_path?(path)
2✔
435
      raise NotImplementedError
×
436
    end
437

438
    # Returns all the files under +path+ (but not in subdirectories) in this revision of the repository.
439
    def files_at_path(_path, with_attrs: true)
2✔
440
      raise NotImplementedError
×
441
    end
442

443
    # Returns all the directories under +path+ (but not in subdirectories) in this revision of the repository.
444
    def directories_at_path(_path, with_attrs: true)
2✔
445
      raise NotImplementedError
×
446
    end
447

448
    # Walks all files and subdirectories starting at +path+ and
449
    # returns an array of tuples containing [path, revision_object]
450
    # for every file and directory discovered in this way
451
    def tree_at_path(_path, with_attrs: true)
2✔
452
      raise NotImplementedError
×
453
    end
454
  end
455

456
  # Exceptions for Files
457
  class FileOutOfDate < StandardError; end
2✔
458
  class FileDoesNotExist < StandardError; end
2✔
459
  # Exceptions for Folders
460
  class FolderDoesNotExist < StandardError; end
2✔
461
  # Exceptions for repo user management
462
  class UserNotFound < StandardError; end
2✔
463
  class UserAlreadyExistent < StandardError; end
2✔
464
  # raised when trying to modify permissions and repo is not in authoritative mode
465
  class NotAuthorityError < StandardError; end
2✔
466
  # raised when configuration is wrong
467
  class ConfigurationError < StandardError; end
2✔
468

469
  #################################################
470
  #  Class File:
471
  #        Files stored in a Revision
472
  #################################################
473
  class RevisionFile
2✔
474
    def initialize(from_revision, args)
2✔
475
      @name = args[:name]
956✔
476
      @path = args[:path]
956✔
477
      @last_modified_revision = args[:last_modified_revision]
956✔
478
      @last_modified_date = args[:last_modified_date]
956✔
479
      @submitted_date = args[:submitted_date]
956✔
480
      @changed = args[:changed]
956✔
481
      @user_id = args[:user_id]
956✔
482
      @mime_type = args[:mime_type]
956✔
483
      @from_revision = from_revision
956✔
484
    end
485

486
    attr_accessor :name, :path, :last_modified_revision, :changed, :submitted_date, :from_revision, :user_id,
2✔
487
                  :mime_type, :last_modified_date
488
  end
489

490
  class RevisionDirectory
2✔
491
    def initialize(from_revision, args)
2✔
492
      @name = args[:name]
6,771✔
493
      @path = args[:path]
6,771✔
494
      @last_modified_revision = args[:last_modified_revision]
6,771✔
495
      @last_modified_date = args[:last_modified_date]
6,771✔
496
      @submitted_date = args[:submitted_date]
6,771✔
497
      @changed = args[:changed]
6,771✔
498
      @user_id = args[:user_id]
6,771✔
499
      @from_revision = from_revision
6,771✔
500
    end
501

502
    attr_accessor :name, :path, :last_modified_revision, :changed, :submitted_date, :from_revision, :user_id,
2✔
503
                  :last_modified_date
504
  end
505

506
  class Transaction
2✔
507
    attr_reader :user_id, :comment, :jobs, :conflicts
2✔
508

509
    def initialize(user_id, comment)
2✔
510
      @user_id = user_id
7,020✔
511
      @comment = comment
7,020✔
512
      @jobs = []
7,020✔
513
      @conflicts = []
7,020✔
514
    end
515

516
    def add_path(path)
2✔
517
      @jobs.push(action: :add_path, path: path)
6,374✔
518
    end
519

520
    def add(path, file_data = nil, mime_type = nil)
2✔
521
      @jobs.push(action: :add, path: path, file_data: file_data, mime_type: mime_type)
783✔
522
    end
523

524
    def remove(path, expected_revision_identifier, keep_folder: true)
2✔
525
      @jobs.push(action: :remove, path: path, expected_revision_identifier: expected_revision_identifier,
3✔
526
                 keep_folder: keep_folder)
527
    end
528

529
    def remove_directory(path, expected_revision_identifier, keep_parent_dir: false)
2✔
530
      @jobs.push(action: :remove_directory, path: path, expected_revision_identifier: expected_revision_identifier,
2✔
531
                 keep_parent_dir: keep_parent_dir)
532
    end
533

534
    def replace(path, file_data, mime_type, expected_revision_identifier)
2✔
535
      @jobs.push(action: :replace, path: path, file_data: file_data, mime_type: mime_type,
109✔
536
                 expected_revision_identifier: expected_revision_identifier)
537
    end
538

539
    def add_conflict(conflict)
2✔
540
      @conflicts.push(conflict)
9✔
541
    end
542

543
    def conflicts?
2✔
544
      @conflicts.size > 0
6,982✔
545
    end
546

547
    def has_jobs?
2✔
548
      @jobs.size > 0
6,558✔
549
    end
550
  end
551

552
  # Gets the configured repository implementation
553
  def self.get_class
2✔
554
    repo_type = Settings.repository.type
38,894✔
555
    case repo_type
38,894✔
556
    when 'git'
557
      GitRepository
182✔
558
    when 'mem'
559
      MemoryRepository
38,712✔
560
    else
561
      raise "Repository implementation not found: #{repo_type}"
×
562
    end
563
  end
564
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc