Add ProgrammingGroup & ProgrammingGroupMembership

* User can create programming group with other users for exercise
* Submission is shared in a group
* Also adjust specs
This commit is contained in:
kiragrammel
2023-08-10 17:07:04 +02:00
committed by Sebastian Serth
parent 0234414bae
commit 319c3ab3b4
42 changed files with 715 additions and 276 deletions

View File

@ -20,3 +20,8 @@ Rails/UnknownEnv:
Rails/I18nLazyLookup: Rails/I18nLazyLookup:
Enabled: false Enabled: false
Rails/DynamicFindBy:
Whitelist:
- find_by_sql # Default value for this cop
- find_by_id_with_type # custom method defined in the `User` model

View File

@ -23,6 +23,15 @@ class ApplicationController < ActionController::Base
@current_user ||= find_or_login_current_user&.store_current_study_group_id(session[:study_group_id]) @current_user ||= find_or_login_current_user&.store_current_study_group_id(session[:study_group_id])
end end
def current_contributor
@current_contributor ||= if session[:pg_id]
current_user.programming_groups.find(session[:pg_id])
else
current_user
end
end
helper_method :current_contributor
def find_or_login_current_user def find_or_login_current_user
login_from_authentication_token || login_from_authentication_token ||
login_from_lti_session || login_from_lti_session ||

View File

@ -100,6 +100,6 @@ class CommunitySolutionsController < ApplicationController
def set_exercise_and_submission def set_exercise_and_submission
@exercise = @community_solution.exercise @exercise = @community_solution.exercise
@submission = current_user.submissions.final.where(exercise_id: @community_solution.exercise.id).order('created_at DESC').first @submission = current_contributor.submissions.final.where(exercise: @community_solution.exercise).order(created_at: :desc).first
end end
end end

View File

@ -17,7 +17,7 @@ module FileParameters
# avoid that public files from other contexts can be created # avoid that public files from other contexts can be created
# `next` is similar to an early return and will proceed with the next iteration of the loop # `next` is similar to an early return and will proceed with the next iteration of the loop
next true if file.context_type == 'Exercise' && file.context_id != exercise.id next true if file.context_type == 'Exercise' && file.context_id != exercise.id
next true if file.context_type == 'Submission' && (file.context.contributor_id != current_user.id || file.context.contributor_type != current_user.class.name) next true if file.context_type == 'Submission' && (file.context.contributor_id != current_contributor.id || file.context.contributor_type != current_contributor.class.name)
next true if file.context_type == 'CommunitySolution' && controller_name != 'community_solutions' next true if file.context_type == 'CommunitySolution' && controller_name != 'community_solutions'
# Optimization: We already queried the ancestor file, let's reuse the object. # Optimization: We already queried the ancestor file, let's reuse the object.

View File

@ -21,18 +21,17 @@ module Lti
# exercise_id.nil? ==> the user has logged out. All session data is to be destroyed # exercise_id.nil? ==> the user has logged out. All session data is to be destroyed
# exercise_id.exists? ==> the user has submitted the results of an exercise to the consumer. # exercise_id.exists? ==> the user has submitted the results of an exercise to the consumer.
# Only the lti_parameters are deleted. # Only the lti_parameters are deleted.
def clear_lti_session_data(exercise_id = nil, _user_id = nil) def clear_lti_session_data(exercise_id = nil)
if exercise_id.nil? if exercise_id.nil?
session.delete(:external_user_id) session.delete(:external_user_id)
session.delete(:study_group_id) session.delete(:study_group_id)
session.delete(:embed_options) session.delete(:embed_options)
session.delete(:lti_exercise_id)
session.delete(:lti_parameters_id)
end end
session.delete(:pg_id)
# March 2022: We temporarily allow reusing the LTI credentials and don't remove them on purpose. # We allow reusing the LTI credentials and don't remove them on purpose.
# This allows users to jump between remote and web evaluation with the same behavior. # This allows users to jump between remote and web evaluation with the same behavior.
# LtiParameter.where(external_users_id: user_id, exercises_id: exercise_id).destroy_all # Also it prevents the user from deleting the lti_parameters for their programming team members.
end end
private :clear_lti_session_data private :clear_lti_session_data
@ -159,20 +158,20 @@ module Lti
session: session.to_hash, session: session.to_hash,
exercise_id: submission.exercise_id, exercise_id: submission.exercise_id,
}) })
normalized_lit_score = submission.normalized_score normalized_lti_score = submission.normalized_score
if submission.before_deadline? if submission.before_deadline?
# Keep the full score # Keep the full score
elsif submission.within_grace_period? elsif submission.within_grace_period?
# Reduce score by 20% # Reduce score by 20%
normalized_lit_score *= 0.8 normalized_lti_score *= 0.8
elsif submission.after_late_deadline? elsif submission.after_late_deadline?
# Reduce score by 100% # Reduce score by 100%
normalized_lit_score *= 0.0 normalized_lti_score *= 0.0
end end
begin begin
response = provider.post_replace_result!(normalized_lit_score) response = provider.post_replace_result!(normalized_lti_score)
{code: response.response_code, message: response.post_response.body, status: response.code_major, score_sent: normalized_lit_score} {code: response.response_code, message: response.post_response.body, status: response.code_major, score_sent: normalized_lti_score}
rescue IMS::LTI::XMLParseError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError rescue IMS::LTI::XMLParseError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError
# A parsing error might happen if the LTI provider is down and doesn't return a valid XML response # A parsing error might happen if the LTI provider is down and doesn't return a valid XML response
{status: 'error'} {status: 'error'}

View File

@ -22,8 +22,7 @@ module SubmissionParameters
def merge_user(params) def merge_user(params)
# The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended. # The study_group_id might not be present in the session (e.g. for internal users), resulting in session[:study_group_id] = nil which is intended.
params.merge( params.merge(
contributor_id: current_user.id, contributor: current_contributor,
contributor_type: current_user.class.name,
study_group_id: current_user.current_study_group_id study_group_id: current_user.current_study_group_id
) )
end end

View File

@ -92,7 +92,7 @@ class ExercisesController < ApplicationController
authorize! authorize!
@feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page], per_page: per_page_param) @feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page], per_page: per_page_param)
@submissions = @feedbacks.map do |feedback| @submissions = @feedbacks.map do |feedback|
feedback.exercise.final_submission(feedback.user) feedback.exercise.final_submission(feedback.user.programming_groups.where(exercise: @exercise).presence || feedback.user)
end end
end end
@ -298,7 +298,7 @@ class ExercisesController < ApplicationController
private :update_exercise_tips private :update_exercise_tips
def implement def implement
user_solved_exercise = @exercise.solved_by?(current_user) user_solved_exercise = @exercise.solved_by?(current_contributor)
count_interventions_today = UserExerciseIntervention.where(user: current_user).where('created_at >= ?', count_interventions_today = UserExerciseIntervention.where(user: current_user).where('created_at >= ?',
Time.zone.now.beginning_of_day).count Time.zone.now.beginning_of_day).count
user_got_intervention_in_exercise = UserExerciseIntervention.where(user: current_user, user_got_intervention_in_exercise = UserExerciseIntervention.where(user: current_user,
@ -330,7 +330,7 @@ class ExercisesController < ApplicationController
@hide_rfc_button = @embed_options[:disable_rfc] @hide_rfc_button = @embed_options[:disable_rfc]
@submission = current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first @submission = current_contributor.submissions.order(created_at: :desc).find_by(exercise: @exercise)
@files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:filepath) @files = (@submission ? @submission.collect_files : @exercise.files).select(&:visible).sort_by(&:filepath)
@paths = collect_paths(@files) @paths = collect_paths(@files)
end end
@ -363,7 +363,7 @@ class ExercisesController < ApplicationController
private :set_available_tips private :set_available_tips
def working_times def working_times
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user) working_time_accumulated = @exercise.accumulated_working_time_for_only(current_contributor)
working_time_75_percentile = @exercise.get_quantiles([0.75]).first working_time_75_percentile = @exercise.get_quantiles([0.75]).first
render(json: {working_time_75_percentile:, render(json: {working_time_75_percentile:,
working_time_accumulated:}) working_time_accumulated:})
@ -375,8 +375,8 @@ class ExercisesController < ApplicationController
render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"}) render(json: {success: 'false', error: "undefined intervention #{params[:intervention_type]}"})
else else
uei = UserExerciseIntervention.new( uei = UserExerciseIntervention.new(
user: current_user, exercise: @exercise, intervention:, user: current_contributor, exercise: @exercise, intervention:,
accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_user) accumulated_worktime_s: @exercise.accumulated_working_time_for_only(current_contributor)
) )
uei.save uei.save
render(json: {success: 'true'}) render(json: {success: 'true'})
@ -465,7 +465,7 @@ class ExercisesController < ApplicationController
def statistics def statistics
# Show general statistic page for specific exercise # Show general statistic page for specific exercise
user_statistics = {'InternalUser' => {}, 'ExternalUser' => {}} contributor_statistics = {'InternalUser' => {}, 'ExternalUser' => {}, 'ProgrammingGroup' => {}}
query = Submission.select('contributor_id, contributor_type, MAX(score) AS maximum_score, COUNT(id) AS runs') query = Submission.select('contributor_id, contributor_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
.where(exercise_id: @exercise.id) .where(exercise_id: @exercise.id)
@ -481,11 +481,11 @@ class ExercisesController < ApplicationController
end end
query.each do |tuple| query.each do |tuple|
user_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple contributor_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple
end end
render locals: { render locals: {
user_statistics:, contributor_statistics:,
} }
end end
@ -495,7 +495,7 @@ class ExercisesController < ApplicationController
if policy(@exercise).detailed_statistics? if policy(@exercise).detailed_statistics?
submissions = Submission.where(contributor: @external_user, exercise: @exercise) submissions = Submission.where(contributor: @external_user, exercise: @exercise)
.in_study_group_of(current_user) .in_study_group_of(current_user)
.order('created_at') .order(:created_at)
@show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' } @show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' }
submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id, interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id,
@ -526,7 +526,8 @@ class ExercisesController < ApplicationController
def submit def submit
@submission = Submission.create(submission_params) @submission = Submission.create(submission_params)
@submission.calculate_score @submission.calculate_score
if @submission.user.external_user? && lti_outcome_service?(@submission.exercise_id, @submission.user.id)
if @submission.users.map {|user| user.external_user? && lti_outcome_service?(@submission.exercise_id, user.id) }.any?
transmit_lti_score transmit_lti_score
else else
redirect_after_submit redirect_after_submit

View File

@ -5,7 +5,7 @@ class FlowrController < ApplicationController
require_user! require_user!
# get the latest submission for this user that also has a test run (i.e. structured_errors if applicable) # get the latest submission for this user that also has a test run (i.e. structured_errors if applicable)
submission = Submission.joins(:testruns) submission = Submission.joins(:testruns)
.where(submissions: {contributor: current_user}) .where(submissions: {contributor: current_contributor})
.includes(structured_errors: [structured_error_attributes: [:error_template_attribute]]) .includes(structured_errors: [structured_error_attributes: [:error_template_attribute]])
.merge(Testrun.order(created_at: :desc)).first .merge(Testrun.order(created_at: :desc)).first

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
class ProgrammingGroupsController < ApplicationController
include CommonBehavior
include LtiHelper
before_action :set_exercise_and_authorize
def new
@programming_group = ProgrammingGroup.new(exercise: @exercise)
authorize!
existing_programming_group = current_user.programming_groups.find_by(exercise: @exercise)
if existing_programming_group
session[:pg_id] = existing_programming_group.id
redirect_to(implement_exercise_path(@exercise),
notice: t("sessions.create_through_lti.session_#{lti_outcome_service?(@exercise.id, current_user.id) ? 'with' : 'without'}_outcome", consumer: @consumer))
end
end
def create
programming_partner_ids = programming_group_params[:programming_partner_ids].split(',').map(&:strip).uniq
users = programming_partner_ids.map do |partner_id|
User.find_by_id_with_type(partner_id)
rescue ActiveRecord::RecordNotFound
partner_id
end
@programming_group = ProgrammingGroup.new(exercise: @exercise, users:)
authorize!
unless programming_partner_ids.include? current_user.id_with_type
@programming_group.add(current_user)
end
create_and_respond(object: @programming_group, path: proc { implement_exercise_path(@exercise) }) do
session[:pg_id] = @programming_group.id
nil
end
end
private
def authorize!
authorize(@programming_group || @programming_groups)
end
def programming_group_params
params.require(:programming_group).permit(:programming_partner_ids)
end
def set_exercise_and_authorize
@exercise = Exercise.find(params[:exercise_id])
authorize(@exercise, :implement?)
end
end

View File

@ -34,6 +34,7 @@ class RemoteEvaluationController < ApplicationController
end end
def try_lti def try_lti
# TODO: Need to consider and support programming groups
if !@submission.user.nil? && lti_outcome_service?(@submission.exercise_id, @submission.user.id) if !@submission.user.nil? && lti_outcome_service?(@submission.exercise_id, @submission.user.id)
lti_response = send_score(@submission) lti_response = send_score(@submission)
process_lti_response(lti_response) process_lti_response(lti_response)

View File

@ -41,9 +41,9 @@ class UserExerciseFeedbacksController < ApplicationController
Sentry.set_extras(params: uef_params) Sentry.set_extras(params: uef_params)
@exercise = Exercise.find(uef_params[:exercise_id]) @exercise = Exercise.find(uef_params[:exercise_id])
rfc = RequestForComment.unsolved.where(exercise_id: @exercise.id, user_id: current_user.id).first rfc = RequestForComment.unsolved.where(exercise_id: @exercise.id, user: current_user).first
submission = begin submission = begin
current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first current_contributor.submissions.where(exercise_id: @exercise.id).order('created_at DESC').first
rescue StandardError rescue StandardError
nil nil
end end
@ -69,11 +69,11 @@ class UserExerciseFeedbacksController < ApplicationController
def update def update
submission = begin submission = begin
current_user.submissions.where(exercise_id: @exercise.id).order('created_at DESC').final.first current_contributor.submissions.where(exercise: @exercise).order(created_at: :desc).final.first
rescue StandardError rescue StandardError
nil nil
end end
rfc = RequestForComment.unsolved.where(exercise_id: @exercise.id, user_id: current_user.id).first rfc = RequestForComment.unsolved.where(exercise: @exercise, user: current_user).first
authorize! authorize!
if @exercise && validate_inputs(uef_params) if @exercise && validate_inputs(uef_params)
path = path =
@ -123,18 +123,15 @@ class UserExerciseFeedbacksController < ApplicationController
params[:user_exercise_feedback][:exercise_id] params[:user_exercise_feedback][:exercise_id]
end end
user_id = current_user.id
user_type = current_user.class.name
latest_submission = Submission latest_submission = Submission
.where(contributor_id: user_id, contributor_type: user_type, exercise_id:) .where(contributor: current_contributor, exercise_id:)
.order(created_at: :desc).final.first .order(created_at: :desc).final.first
authorize(latest_submission, :show?) authorize(latest_submission, :show?)
params[:user_exercise_feedback] params[:user_exercise_feedback]
.permit(:feedback_text, :difficulty, :exercise_id, :user_estimated_worktime) .permit(:feedback_text, :difficulty, :exercise_id, :user_estimated_worktime)
.merge(user_id:, .merge(user: current_user,
user_type:,
submission: latest_submission, submission: latest_submission,
normalized_score: latest_submission&.normalized_score) normalized_score: latest_submission&.normalized_score)
end end

View File

@ -9,9 +9,9 @@ module StatisticsHelper
def statistics_data def statistics_data
[ [
{ {
key: 'users', key: 'contributors',
name: t('statistics.sections.users'), name: t('statistics.sections.contributors'),
entries: user_statistics, entries: contributor_statistics,
}, },
{ {
key: 'exercises', key: 'exercises',
@ -26,7 +26,7 @@ module StatisticsHelper
] ]
end end
def user_statistics def contributor_statistics
[ [
{ {
key: 'internal_users', key: 'internal_users',
@ -40,10 +40,15 @@ module StatisticsHelper
data: ExternalUser.count, data: ExternalUser.count,
url: external_users_path, url: external_users_path,
}, },
{
key: 'programming_groups',
name: t('activerecord.models.programming_group.other'),
data: ProgrammingGroup.count,
},
{ {
key: 'currently_active', key: 'currently_active',
name: t('statistics.entries.users.currently_active'), name: t('statistics.entries.users.currently_active'),
data: Submission.where(created_at: 5.minutes.ago.., contributor_type: ExternalUser.name).distinct.count(:contributor_id), data: Submission.from(Submission.where(created_at: 5.minutes.ago..).distinct.select(:contributor_id, :contributor_type)).count,
url: statistics_graphs_path, url: statistics_graphs_path,
}, },
] ]
@ -108,6 +113,7 @@ module StatisticsHelper
] ]
end end
# TODO: Need to consider and support programming groups
def user_activity_live_data def user_activity_live_data
[ [
{ {
@ -209,6 +215,7 @@ module StatisticsHelper
] ]
end end
# TODO: Need to consider and support programming groups
def ranged_user_data(interval = 'year', from = DateTime.new(0), to = DateTime.now) def ranged_user_data(interval = 'year', from = DateTime.new(0), to = DateTime.now)
[ [
{ {

View File

@ -29,7 +29,7 @@ class Exercise < ApplicationRecord
has_many :external_users, source: :contributor, source_type: 'ExternalUser', through: :submissions has_many :external_users, source: :contributor, source_type: 'ExternalUser', through: :submissions
has_many :internal_users, source: :contributor, source_type: 'InternalUser', through: :submissions has_many :internal_users, source: :contributor, source_type: 'InternalUser', through: :submissions
alias users external_users has_many :programming_groups
scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') } scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') }
@ -57,10 +57,10 @@ class Exercise < ApplicationRecord
end end
def finishers_percentage def finishers_percentage
if users.distinct.count.zero? if contributors.empty?
0 0
else else
(100.0 / users.distinct.count * finishers.count).round(2) (100.0 / contributors.size * finishers_count).round(2)
end end
end end
@ -72,8 +72,11 @@ class Exercise < ApplicationRecord
end end
def average_number_of_submissions def average_number_of_submissions
user_count = internal_users.distinct.count + external_users.distinct.count contributors.empty? ? 0 : submissions.count / contributors.size.to_f
user_count.zero? ? 0 : submissions.count / user_count.to_f end
def contributors
@contributors ||= internal_users.distinct + external_users.distinct + programming_groups.distinct
end end
def time_maximum_score(contributor) def time_maximum_score(contributor)
@ -201,6 +204,17 @@ class Exercise < ApplicationRecord
total_working_time total_working_time
FROM working_times_with_index FROM working_times_with_index
JOIN internal_users ON contributor_type = 'InternalUser' AND contributor_id = internal_users.id JOIN internal_users ON contributor_type = 'InternalUser' AND contributor_id = internal_users.id
UNION ALL
SELECT index,
contributor_id,
contributor_type,
concat('PG ', programming_groups.id::varchar) AS name,
score,
start_time,
working_time_per_score,
total_working_time
FROM working_times_with_index
JOIN programming_groups ON contributor_type = 'ProgrammingGroup' AND contributor_id = programming_groups.id
ORDER BY index, score ASC; ORDER BY index, score ASC;
" "
end end
@ -262,7 +276,7 @@ class Exercise < ApplicationRecord
(created_at - Lag(created_at) OVER (partition BY contributor_id, exercise_id ORDER BY created_at)) AS working_time (created_at - Lag(created_at) OVER (partition BY contributor_id, exercise_id ORDER BY created_at)) AS working_time
FROM submissions FROM submissions
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])} WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}
AND contributor_type = 'ExternalUser' AND contributor_type IN ('ExternalUser', 'ProgrammingGroup')
GROUP BY contributor_id, GROUP BY contributor_id,
id, id,
exercise_id), max_points AS exercise_id), max_points AS
@ -367,7 +381,7 @@ class Exercise < ApplicationRecord
end end
def retrieve_working_time_statistics def retrieve_working_time_statistics
@working_time_statistics = {'InternalUser' => {}, 'ExternalUser' => {}} @working_time_statistics = {'InternalUser' => {}, 'ExternalUser' => {}, 'ProgrammingGroup' => {}}
self.class.connection.exec_query(user_working_time_query).each do |tuple| self.class.connection.exec_query(user_working_time_query).each do |tuple|
tuple = tuple.merge('working_time' => format_time_difference(tuple['working_time'])) tuple = tuple.merge('working_time' => format_time_difference(tuple['working_time']))
@working_time_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple @working_time_statistics[tuple['contributor_type']][tuple['contributor_id'].to_i] = tuple
@ -532,9 +546,8 @@ class Exercise < ApplicationRecord
maximum_score(contributor).to_i == maximum_score.to_i maximum_score(contributor).to_i == maximum_score.to_i
end end
def finishers def finishers_count
ExternalUser.joins(:submissions).where(submissions: {exercise_id: id, score: maximum_score, Submission.from(submissions.where(score: maximum_score, cause: %w[submit assess remoteSubmit remoteAssess]).group(:contributor_id, :contributor_type).select(:contributor_id, :contributor_type), 'submissions').count
cause: %w[submit assess remoteSubmit remoteAssess]}).distinct
end end
def set_default_values def set_default_values

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
class ProgrammingGroup < ApplicationRecord
include Contributor
has_many :programming_group_memberships, dependent: :destroy
has_many :external_users, through: :programming_group_memberships, source_type: 'ExternalUser', source: :user
has_many :internal_users, through: :programming_group_memberships, source_type: 'InternalUser', source: :user
belongs_to :exercise
validate :group_size
validate :no_erroneous_users
accepts_nested_attributes_for :programming_group_memberships
def initialize(attributes = nil)
@erroneous_users = []
super
end
def external_user?
false
end
def internal_user?
false
end
def self.nested_resource?
true
end
def programming_group?
true
end
def add(user)
# Accessing the `users` method here will preload all users, which is otherwise done during validation.
internal_users << user if user.internal_user? && users.exclude?(user)
external_users << user if user.external_user? && users.exclude?(user)
user
end
def to_s
displayname
end
def displayname
"Programming Group #{id}"
end
def programming_partner_ids
users.map(&:id_with_type)
end
def users
internal_users + external_users
end
def users=(users)
self.internal_users = []
self.external_users = []
users.each do |user|
next @erroneous_users << user unless user.is_a?(User)
add(user)
end
end
private
def group_size
if users.size < 2
errors.add(:base, :size_too_small)
end
end
def no_erroneous_users
@erroneous_users.each do |partner_id|
errors.add(:base, :invalid_partner_id, partner_id:)
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ProgrammingGroupMembership < ApplicationRecord
belongs_to :user, polymorphic: true
belongs_to :programming_group
validate :unique_membership_for_exercise
validates :user_id, uniqueness: {scope: %i[programming_group_id user_type]}
def unique_membership_for_exercise
if user.programming_groups.where(exercise: programming_group.exercise).any?
errors.add(:base, :already_exists, id_with_type: user.id_with_type)
end
end
end

View File

@ -24,6 +24,9 @@ class Submission < ApplicationRecord
belongs_to :internal_users, lambda { belongs_to :internal_users, lambda {
where(submissions: {contributor_type: 'InternalUser'}).includes(:submissions) where(submissions: {contributor_type: 'InternalUser'}).includes(:submissions)
}, foreign_key: :contributor_id, class_name: 'InternalUser', optional: true }, foreign_key: :contributor_id, class_name: 'InternalUser', optional: true
belongs_to :programming_groups, lambda {
where(submissions: {contributor_type: 'ProgrammingGroup'}).includes(:submissions)
}, foreign_key: :contributor_id, class_name: 'ProgrammingGroup', optional: true
delegate :execution_environment, to: :exercise delegate :execution_environment, to: :exercise
scope :final, -> { where(cause: %w[submit remoteSubmit]) } scope :final, -> { where(cause: %w[submit remoteSubmit]) }
@ -49,12 +52,6 @@ class Submission < ApplicationRecord
# after_save :trigger_working_times_action_cable # after_save :trigger_working_times_action_cable
def build_files_hash(files, attribute)
files.map(&attribute.to_proc).zip(files).to_h
end
private :build_files_hash
def collect_files def collect_files
@collect_files ||= begin @collect_files ||= begin
ancestors = build_files_hash(exercise.files.includes(:file_type), :id) ancestors = build_files_hash(exercise.files.includes(:file_type), :id)
@ -202,8 +199,16 @@ class Submission < ApplicationRecord
%w[study_group_id exercise_id cause] %w[study_group_id exercise_id cause]
end end
def users
contributor.try(:users) || [contributor]
end
private private
def build_files_hash(files, attribute)
files.map(&attribute.to_proc).zip(files).to_h
end
def prepared_runner def prepared_runner
request_time = Time.zone.now request_time = Time.zone.now
begin begin

View File

@ -9,6 +9,8 @@ class User < ApplicationRecord
has_many :authentication_token, dependent: :destroy has_many :authentication_token, dependent: :destroy
has_many :study_group_memberships, as: :user has_many :study_group_memberships, as: :user
has_many :study_groups, through: :study_group_memberships, as: :user has_many :study_groups, through: :study_group_memberships, as: :user
has_many :programming_group_memberships, as: :user
has_many :programming_groups, through: :programming_group_memberships, as: :user
has_many :exercises, as: :user has_many :exercises, as: :user
has_many :file_types, as: :user has_many :file_types, as: :user
has_many :submissions, as: :contributor has_many :submissions, as: :contributor
@ -43,6 +45,10 @@ class User < ApplicationRecord
is_a?(ExternalUser) is_a?(ExternalUser)
end end
def programming_group?
false
end
def learner? def learner?
return true if current_study_group_id.nil? return true if current_study_group_id.nil?
@ -57,6 +63,10 @@ class User < ApplicationRecord
@admin ||= platform_admin? @admin ||= platform_admin?
end end
def id_with_type
self.class.name.downcase.first + id.to_s
end
def store_current_study_group_id(study_group_id) def store_current_study_group_id(study_group_id)
@current_study_group_id = study_group_id @current_study_group_id = study_group_id
self self
@ -79,6 +89,16 @@ class User < ApplicationRecord
} }
end end
def self.find_by_id_with_type(id_with_type)
if id_with_type[0].casecmp('e').zero?
ExternalUser.find(id_with_type[1..])
elsif id_with_type[0].casecmp('i').zero?
InternalUser.find(id_with_type[1..])
else
raise ActiveRecord::RecordNotFound
end
end
def self.ransackable_attributes(auth_object) def self.ransackable_attributes(auth_object)
if auth_object.present? && auth_object.admin? if auth_object.present? && auth_object.admin?
%w[name email external_id consumer_id platform_admin] %w[name email external_id consumer_id platform_admin]

View File

@ -49,6 +49,13 @@ class ApplicationPolicy
end end
private :teacher_in_study_group? private :teacher_in_study_group?
def author_in_programming_group?
return false unless @record.contributor.programming_group?
@record.contributor.users.include?(@user)
end
private :author_in_programming_group?
def initialize(user, record) def initialize(user, record)
@user = user @user = user
@record = record @record = record

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ProgrammingGroupPolicy < ApplicationPolicy
def new?
everyone
end
def create?
everyone
end
end

View File

@ -9,7 +9,7 @@ class SubmissionPolicy < ApplicationPolicy
# download_submission_file? is used in the live_streams_controller.rb # download_submission_file? is used in the live_streams_controller.rb
%i[download? download_file? download_submission_file? run? score? show? statistics? stop? test? %i[download? download_file? download_submission_file? run? score? show? statistics? stop? test?
insights?].each do |action| insights?].each do |action|
define_method(action) { admin? || author? } define_method(action) { admin? || author? || author_in_programming_group? }
end end
def render_file? def render_file?

View File

@ -4,7 +4,7 @@ h1 = @execution_environment
table.table.table-striped class="#{@execution_environment.present? ? 'sortable' : ''}" table.table.table-striped class="#{@execution_environment.present? ? 'sortable' : ''}"
thead thead
tr tr
- ['.exercise', '.users', '.score', '.maximum_score', '.stddev_score', '.percentage_correct', '.runs', '.worktime', '.stddev_worktime'].each do |title| - ['.exercise', '.users_and_programming_groups', '.score', '.maximum_score', '.stddev_score', '.percentage_correct', '.runs', '.worktime', '.stddev_worktime'].each do |title|
th.header = t(title) th.header = t(title)
tbody tbody
- @execution_environment.exercises.each do |exercise| - @execution_environment.exercises.each do |exercise|

View File

@ -33,7 +33,7 @@ h4.mt-4 = t('activerecord.attributes.exercise_collections.exercises')
tr tr
th = '#' th = '#'
th = t('activerecord.attributes.exercise.title') th = t('activerecord.attributes.exercise.title')
th = t('activerecord.attributes.exercise.number_of_users') th = t('activerecord.attributes.exercise.number_of_users_and_programming_groups')
th = t('activerecord.attributes.exercise.distinct_final_submissions') th = t('activerecord.attributes.exercise.distinct_final_submissions')
th = t('activerecord.attributes.exercise.finishing_rate') th = t('activerecord.attributes.exercise.finishing_rate')
th = t('activerecord.attributes.exercise.average_score_percentage') th = t('activerecord.attributes.exercise.average_score_percentage')
@ -44,8 +44,8 @@ h4.mt-4 = t('activerecord.attributes.exercise_collections.exercises')
tr tr
td = exercise_collection_item.position td = exercise_collection_item.position
td = link_to_if(policy(exercise).show?, exercise.title, exercise) td = link_to_if(policy(exercise).show?, exercise.title, exercise)
td = exercise.users.distinct.count td = exercise.contributors.size
td = exercise.submissions.send(:final).distinct.count(:user_id) td = exercise.submissions.send(:final).distinct.count(:contributor_id)
td = exercise.finishers_percentage td = exercise.finishers_percentage
td = exercise.average_percentage td = exercise.average_percentage
td = link_to(t('shared.statistics'), statistics_exercise_path(exercise), 'data-turbolinks' => "false") if policy(exercise).statistics? td = link_to(t('shared.statistics'), statistics_exercise_path(exercise), 'data-turbolinks' => "false") if policy(exercise).statistics?

View File

@ -52,7 +52,7 @@ div.d-grid id='output_sidebar_uncollapsed' class='d-none col-sm-12 enforce-botto
| 0 | 0
= row(label: 'exercises.implement.feedback') = row(label: 'exercises.implement.feedback')
= row(label: 'exercises.implement.messages') = row(label: 'exercises.implement.messages')
#score data-maximum-score=@exercise.maximum_score data-score=@exercise.final_submission(current_user).try(:score) #score data-maximum-score=@exercise.maximum_score data-score=@exercise.final_submission(current_contributor).try(:score)
h4 h4
span == "#{t('activerecord.attributes.submission.score')}:&nbsp;" span == "#{t('activerecord.attributes.submission.score')}:&nbsp;"
span.score span.score

View File

@ -5,25 +5,26 @@
- append_javascript_pack_tag('d3-tip') - append_javascript_pack_tag('d3-tip')
h1 = @exercise h1 = @exercise
= row(label: '.participants', value: @exercise.users.distinct.count) = row(label: '.participants', value: @exercise.contributors.size)
- [:intermediate, :final].each do |scope| - [:intermediate, :final].each do |scope|
= row(label: ".#{scope}_submissions") do = row(label: ".#{scope}_submissions") do
= "#{@exercise.submissions.send(scope).count} (#{t('.users', count: @exercise.submissions.send(scope).distinct.count(:contributor_id))})" /TODO: Refactor next line
= "#{@exercise.submissions.send(scope).count} (#{t('.users_and_programming_groups', count: Submission.from(@exercise.submissions.send(scope).group(:contributor_id, :contributor_type).select(:contributor_id, :contributor_type)).count)})"
= row(label: '.finishing_rate') do = row(label: '.finishing_rate') do
p p
- if @exercise.finishers.count - if @exercise.finishers_count
span.number span.number
= @exercise.finishers.count = @exercise.finishers_count
=<> t('shared.out_of') =<> t('shared.out_of')
span.number span.number
= @exercise.users.distinct.count = @exercise.contributors.size
=< t('exercises.statistics.external_users') =< t('exercises.statistics.external_users')
- else - else
= empty = empty
- finishers_count = @exercise.users.distinct.count - finishers_count = @exercise.contributors.size
- finishers_percentage = finishers_count == 0 ? 0 : (100.0 / finishers_count * @exercise.finishers.count).round(2) - finishers_percentage = finishers_count == 0 ? 0 : (100.0 / finishers_count * @exercise.finishers_count).round(2)
p = progress_bar(finishers_percentage) p = progress_bar(finishers_percentage)
= row(label: '.average_score') do = row(label: '.average_score') do
@ -42,7 +43,7 @@ h1 = @exercise
= row(label: '.average_worktime') do = row(label: '.average_worktime') do
p = @exercise.average_working_time p = @exercise.average_working_time
- Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users')].each_pair do |symbol, label| - Hash[:internal_users => t('.internal_users'), :external_users => t('.external_users'), :programming_groups => t('.programming_groups')].each_pair do |symbol, label|
- submissions = Submission.where(contributor: @exercise.send(symbol), exercise: @exercise).in_study_group_of(current_user) - submissions = Submission.where(contributor: @exercise.send(symbol), exercise: @exercise).in_study_group_of(current_user)
- if !policy(@exercise).detailed_statistics? - if !policy(@exercise).detailed_statistics?
- submissions = submissions.final - submissions = submissions.final

View File

@ -0,0 +1,7 @@
= form_for(@programming_group, url: exercise_programming_groups_path) do |f|
= render('shared/form_errors', object: @programming_group)
.mb-3
= f.label(:programming_partner_ids, class: 'form-label')
= f.text_field(:programming_partner_ids, class: 'form-control', required: true, value: (@programming_group.programming_partner_ids - [current_user.id_with_type]).join(', '))
.help-block.form-text = t('.hints.programming_partner_ids')
.actions.mb-0 = render('shared/submit_button', f: f, object: @programming_group)

View File

@ -0,0 +1,14 @@
h1 = t('shared.new_model', model: ProgrammingGroup.model_name.human)
p
=> t('programming_groups.new.own_user_id')
b
= current_user.id_with_type
p
= t('programming_groups.new.enter_partner_id', exercise_title: @exercise.title)
= render('form')
div.mt-4
a.btn.btn-success href=new_exercise_programming_group_path(@exercise) == t('programming_groups.new.check_invitation')
p.mt-4
== t('programming_groups.new.work_alone', path: implement_exercise_path(@exercise))

View File

@ -22,7 +22,7 @@ h1 = Submission.model_name.human(count: 2)
- @submissions.each do |submission| - @submissions.each do |submission|
tr tr
td = link_to_if(submission.exercise && policy(submission.exercise).show?, submission.exercise, submission.exercise) td = link_to_if(submission.exercise && policy(submission.exercise).show?, submission.exercise, submission.exercise)
td = link_to_if(policy(submission.contributor).show?, submission.contributor, submission.contributor) td = link_to_if(submission.contributor.is_a?(User) && policy(submission.contributor).show?, submission.contributor, submission.contributor)
td = t("submissions.causes.#{submission.cause}") td = t("submissions.causes.#{submission.cause}")
td = submission.score td = submission.score
td = l(submission.created_at, format: :short) td = l(submission.created_at, format: :short)

View File

@ -8,7 +8,7 @@
h1 = @submission h1 = @submission
= row(label: 'submission.exercise', value: link_to_if(policy(@submission.exercise).show?, @submission.exercise, @submission.exercise)) = row(label: 'submission.exercise', value: link_to_if(policy(@submission.exercise).show?, @submission.exercise, @submission.exercise))
= row(label: 'submission.contributor', value: link_to_if(policy(@submission.contributor).show?, @submission.contributor, @submission.contributor)) = row(label: 'submission.contributor', value: link_to_if(@submission.contributor.is_a?(User) && policy(@submission.contributor).show?, @submission.contributor, @submission.contributor))
= row(label: 'submission.study_group', value: link_to_if(@submission.study_group.present? && policy(@submission.study_group).show?, @submission.study_group, @submission.study_group)) = row(label: 'submission.study_group', value: link_to_if(@submission.study_group.present? && policy(@submission.study_group).show?, @submission.study_group, @submission.study_group))
= row(label: 'submission.cause', value: t("submissions.causes.#{@submission.cause}")) = row(label: 'submission.cause', value: t("submissions.causes.#{@submission.cause}"))
= row(label: 'submission.score', value: @submission.score) = row(label: 'submission.score', value: @submission.score)

View File

@ -45,7 +45,7 @@ de:
maximum_score: Erreichbare Punktzahl maximum_score: Erreichbare Punktzahl
submission_deadline: Abgabefrist submission_deadline: Abgabefrist
late_submission_deadline: Verspätete Abgabefrist late_submission_deadline: Verspätete Abgabefrist
number_of_users: "# Nutzer" number_of_users_and_programming_groups: "# Nutzer und Programmiergruppen"
public: Öffentlich public: Öffentlich
selection: Ausgewählt selection: Ausgewählt
title: Titel title: Titel
@ -56,6 +56,10 @@ de:
token: "Aufgaben-Token" token: "Aufgaben-Token"
uuid: UUID uuid: UUID
unpublished: Deaktiviert unpublished: Deaktiviert
programming_group:
programming_partner_ids: Nutzer-IDs der Programmierpartner
programming_group/programming_group_memberships:
base: Programmiergruppenmitgliedschaft
proxy_exercise: proxy_exercise:
title: Title title: Title
files_count: Anzahl der Aufgaben files_count: Anzahl der Aufgaben
@ -172,7 +176,7 @@ de:
exercises: "Aufgaben" exercises: "Aufgaben"
solutions: "Gesamtanzahl Lösungsversuche" solutions: "Gesamtanzahl Lösungsversuche"
submissions: "Gesamtanzahl Submissions" submissions: "Gesamtanzahl Submissions"
users: "Teilnehmer" users_and_programming_groups: "Teilnehmer und Programmiergruppen"
user_exercise_feedback: user_exercise_feedback:
user: "Autor" user: "Autor"
exercise: "Aufgabe" exercise: "Aufgabe"
@ -225,6 +229,12 @@ de:
internal_user: internal_user:
one: Interner Nutzer one: Interner Nutzer
other: Interne Nutzer other: Interne Nutzer
programming_group:
one: Programmiergruppe
other: Programmiergruppen
programming_group_membership:
one: Programmiergruppenmitgliedschaft
other: Programmiergruppenmitgliedschaften
request_for_comment: request_for_comment:
one: Kommentaranfrage one: Kommentaranfrage
other: Kommentaranfragen other: Kommentaranfragen
@ -259,6 +269,11 @@ de:
attributes: attributes:
password: password:
weak: ist zu schwach. Versuchen Sie es mit einem langen Passwort, welches Groß-, Kleinbuchstaben, Zahlen und Sonderzeichen enthält. weak: ist zu schwach. Versuchen Sie es mit einem langen Passwort, welches Groß-, Kleinbuchstaben, Zahlen und Sonderzeichen enthält.
programming_group:
size_too_small: Die Größe dieser Programmiergruppe ist zu klein. Geben Sie mindestens eine andere Nutzer-ID an.
invalid_partner_id: Die Nutzer-ID '%{partner_id}' ist ungültig und wurde entfernt. Bitte überprüfen Sie die Nutzer-IDs der Programmierpartner.
programming_group_membership:
already_exists: 'existiert bereits für diese Aufgabe für den Nutzer mit der ID %{id_with_type}.'
admin: admin:
dashboard: dashboard:
show: show:
@ -346,7 +361,7 @@ de:
permission_denied: Der Zugriff auf die angeforderte Datei wurde verweigert. Bitte überprüfen Sie, dass die Datei existiert, der aktuelle Benutzer Leseberechtigungen besitzt und versuchen Sie ggf. die Datei mit "root"-Rechten anzufordern. Dazu müssen Sie den "sudo"-Schalter neben der Befehlszeile aktivieren und anschließend das Dateisystem vor dem Herunterladen einer Datei aktualisieren. permission_denied: Der Zugriff auf die angeforderte Datei wurde verweigert. Bitte überprüfen Sie, dass die Datei existiert, der aktuelle Benutzer Leseberechtigungen besitzt und versuchen Sie ggf. die Datei mit "root"-Rechten anzufordern. Dazu müssen Sie den "sudo"-Schalter neben der Befehlszeile aktivieren und anschließend das Dateisystem vor dem Herunterladen einer Datei aktualisieren.
statistics: statistics:
exercise: Übung exercise: Übung
users: Anzahl (externer) Nutzer users_and_programming_groups: Anzahl Nutzer und Programmiergruppen
score: Durchschnittliche Punktzahl score: Durchschnittliche Punktzahl
stddev_score: stdabw (Punktzahl) stddev_score: stdabw (Punktzahl)
stddev_worktime: stdabw (Arbeitszeit) stddev_worktime: stdabw (Arbeitszeit)
@ -519,7 +534,7 @@ de:
final_submissions: Finale Abgaben final_submissions: Finale Abgaben
intermediate_submissions: Intermediäre Abgaben intermediate_submissions: Intermediäre Abgaben
participants: Bearbeitende Nutzer participants: Bearbeitende Nutzer
users: '%{count} verschiedene Nutzer' users_and_programming_groups: '%{count} verschiedene Nutzer und Programmiergruppen'
user: Nutzer user: Nutzer
score: Maximale Punktzahl score: Maximale Punktzahl
deadline: Abgabefrist deadline: Abgabefrist
@ -528,6 +543,7 @@ de:
average_worktime: Durchschnittliche Arbeitszeit average_worktime: Durchschnittliche Arbeitszeit
internal_users: Interne Nutzer internal_users: Interne Nutzer
external_users: Externe Nutzer external_users: Externe Nutzer
programming_groups: Programmiergruppen
finishing_rate: Abschlussrate finishing_rate: Abschlussrate
submit: submit:
failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut. failure: Beim Übermitteln Ihrer Punktzahl ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
@ -563,6 +579,15 @@ de:
proxy_exercises: proxy_exercises:
index: index:
clone: Duplizieren clone: Duplizieren
programming_groups:
form:
hints:
programming_partner_ids: "Sie können mehrere Nutzer-IDs mit Kommata getrennt eingeben, wie z.B.: 'e123, e234'."
new:
check_invitation: "Einladung prüfen"
enter_partner_id: "Bitte geben Sie hier die Nutzer-IDs der Personen ein, mit denen Sie zusammen die Aufgabe '%{exercise_title}' lösen möchten. Beachten Sie jedoch, dass anschließend keiner aus der Gruppe austreten kann. Alle Teammitglieder können also sehen, was Sie in dieser Aufgabe schreiben und umgekehrt. Für die nächste Aufgabe können Sie sich erneuert entscheiden, ob und mit wem Sie zusammen arbeiten möchten."
own_user_id: "Ihre Nutzer-ID:"
work_alone: "Sie können sich einmalig dafür entscheiden, die Aufgabe alleine zu bearbeiten. Anschließend können Sie jedoch nicht mehr in die Gruppenarbeit für diese Aufgabe wechseln. Klicken Sie <a href=%{path})>hier</a>, um die Aufgabe im Einzelmodus zu starten."
external_users: external_users:
statistics: statistics:
title: Statistiken für Externe Benutzer title: Statistiken für Externe Benutzer
@ -967,7 +992,7 @@ de:
subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht." subscription_not_existent: "Das Abonnement, von dem Sie sich abmelden wollen, existiert nicht."
statistics: statistics:
sections: sections:
users: "Benutzer" contributors: "Mitwirkende"
exercises: "Aufgaben" exercises: "Aufgaben"
request_for_comments: "Kommentaranfragen" request_for_comments: "Kommentaranfragen"
entries: entries:

View File

@ -45,7 +45,7 @@ en:
maximum_score: Maximum Score maximum_score: Maximum Score
submission_deadline: Submission Deadline submission_deadline: Submission Deadline
late_submission_deadline: Late Submission Deadline late_submission_deadline: Late Submission Deadline
number_of_users: "# Users" number_of_users_and_programming_groups: "# Users and Programming Groups"
public: Public public: Public
selection: Selected selection: Selected
title: Title title: Title
@ -56,6 +56,10 @@ en:
token: "Exercise Token" token: "Exercise Token"
uuid: UUID uuid: UUID
unpublished: Unpublished unpublished: Unpublished
programming_group:
programming_partner_ids: Programming Partner IDs
programming_group/programming_group_memberships:
base: Programming Group Membership
proxy_exercise: proxy_exercise:
title: Title title: Title
files_count: Exercises Count files_count: Exercises Count
@ -172,7 +176,7 @@ en:
exercises: "Exercises" exercises: "Exercises"
solutions: "Solution Attempts (accumulated)" solutions: "Solution Attempts (accumulated)"
submissions: "Submissions (accumulated)" submissions: "Submissions (accumulated)"
users: "Users" users_and_programming_groups: "Users and Programming Groups"
user_exercise_feedback: user_exercise_feedback:
user: "Author" user: "Author"
exercise: "Exercise" exercise: "Exercise"
@ -225,6 +229,12 @@ en:
internal_user: internal_user:
one: Internal User one: Internal User
other: Internal Users other: Internal Users
programming_group:
one: Programming Group
other: Programming Groups
programming_group_membership:
one: Programming Group Membership
other: Programming Group Memberships
request_for_comment: request_for_comment:
one: Request for Comments one: Request for Comments
other: Requests for Comments other: Requests for Comments
@ -259,6 +269,11 @@ en:
attributes: attributes:
password: password:
weak: is too weak. Try to use a long password with upper and lower case letters, numbers and special characters. weak: is too weak. Try to use a long password with upper and lower case letters, numbers and special characters.
programming_group:
size_too_small: The size of this programming group is too small. Enter at least one other user ID to work with.
invalid_partner_id: The user ID '%{partner_id}' is invalid and was removed. Please check the user IDs of your programming partners.
programming_group_membership:
already_exists: 'already exists for this exercise for the user with ID %{id_with_type}.'
admin: admin:
dashboard: dashboard:
show: show:
@ -346,7 +361,7 @@ en:
permission_denied: Access to the requested file has been denied. Please verify that the file exists, the current user has read permissions, and try requesting the file with "root" privileges if necessary. To retrieve files as "root", you must enable the "sudo" switch shown next to the command input and then reload the file system before accessing any file. permission_denied: Access to the requested file has been denied. Please verify that the file exists, the current user has read permissions, and try requesting the file with "root" privileges if necessary. To retrieve files as "root", you must enable the "sudo" switch shown next to the command input and then reload the file system before accessing any file.
statistics: statistics:
exercise: Exercise exercise: Exercise
users: (External) Users Count users_and_programming_groups: Users and Programming Groups Count
score: Average Score score: Average Score
stddev_score: stddev (score) stddev_score: stddev (score)
stddev_worktime: stddev (working time) stddev_worktime: stddev (working time)
@ -519,7 +534,7 @@ en:
final_submissions: Final Submissions final_submissions: Final Submissions
intermediate_submissions: Intermediate Submissions intermediate_submissions: Intermediate Submissions
participants: Participating Users participants: Participating Users
users: '%{count} distinct users' users_and_programming_groups: '%{count} distinct users and programming groups'
user: User user: User
score: Maximum Score score: Maximum Score
deadline: Deadline deadline: Deadline
@ -528,6 +543,7 @@ en:
average_worktime: Average Working Time average_worktime: Average Working Time
internal_users: Internal Users internal_users: Internal Users
external_users: External Users external_users: External Users
programming_groups: Programming Groups
finishing_rate: Finishing Rate finishing_rate: Finishing Rate
submit: submit:
failure: An error occurred while transmitting your score. Please try again later. failure: An error occurred while transmitting your score. Please try again later.
@ -563,6 +579,15 @@ en:
proxy_exercises: proxy_exercises:
index: index:
clone: Duplicate clone: Duplicate
programming_groups:
form:
hints:
programming_partner_ids: "You can enter several user IDs separated by commas such as 'e123, e234'."
new:
check_invitation: "Check invitation"
enter_partner_id: "Please enter the user IDs from the practice partners with whom you want to solve the exercise '%{exercise_title}'. However, note that no one can leave the group afterward. Hence, all team members can see what you write in this exercise and vice versa. For the next exercise, you can decide again whether and with whom you want to work together."
own_user_id: "Your user ID:"
work_alone: "You can choose once to work on the exercise alone. Afterward, however, you will not be able to switch to group work for this exercise. Click <a href=%{path}>here</a> to get to the exercise in single mode."
external_users: external_users:
statistics: statistics:
title: External User Statistics title: External User Statistics
@ -967,7 +992,7 @@ en:
subscription_not_existent: "The subscription you want to unsubscribe from does not exist." subscription_not_existent: "The subscription you want to unsubscribe from does not exist."
statistics: statistics:
sections: sections:
users: "Users" contributors: "Contributors"
exercises: "Exercises" exercises: "Exercises"
request_for_comments: "Requests for Comment" request_for_comments: "Requests for Comment"
entries: entries:

View File

@ -96,6 +96,8 @@ Rails.application.routes.draw do
post :export_external_check post :export_external_check
post :export_external_confirm post :export_external_confirm
end end
resources :programming_groups, only: %i[new create]
end end
resources :exercise_collections do resources :exercise_collections do

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class CreateProgrammingGroups < ActiveRecord::Migration[7.0]
def change
create_table :programming_groups do |t|
t.belongs_to :exercise, foreign_key: true, null: false, index: true
t.timestamps
end
end
end

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateProgrammingGroupMemberships < ActiveRecord::Migration[7.0]
def change
create_table :programming_group_memberships, id: :uuid do |t|
t.belongs_to :programming_group, foreign_key: true, null: false, index: true
t.belongs_to :user, polymorphic: true, null: false, index: true
t.timestamps
end
end
end

View File

@ -351,6 +351,23 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_27_080619) do
t.index ["external_users_id"], name: "index_lti_parameters_on_external_users_id" t.index ["external_users_id"], name: "index_lti_parameters_on_external_users_id"
end end
create_table "programming_group_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.bigint "programming_group_id", null: false
t.string "user_type", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["programming_group_id"], name: "index_programming_group_memberships_on_programming_group_id"
t.index ["user_type", "user_id"], name: "index_programming_group_memberships_on_user"
end
create_table "programming_groups", force: :cascade do |t|
t.bigint "exercise_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["exercise_id"], name: "index_programming_groups_on_exercise_id"
end
create_table "proxy_exercises", id: :serial, force: :cascade do |t| create_table "proxy_exercises", id: :serial, force: :cascade do |t|
t.string "title" t.string "title"
t.string "description" t.string "description"
@ -582,6 +599,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_27_080619) do
add_foreign_key "exercise_tips", "exercise_tips", column: "parent_exercise_tip_id" add_foreign_key "exercise_tips", "exercise_tips", column: "parent_exercise_tip_id"
add_foreign_key "exercise_tips", "exercises" add_foreign_key "exercise_tips", "exercises"
add_foreign_key "exercise_tips", "tips" add_foreign_key "exercise_tips", "tips"
add_foreign_key "programming_group_memberships", "programming_groups"
add_foreign_key "programming_groups", "exercises"
add_foreign_key "remote_evaluation_mappings", "study_groups" add_foreign_key "remote_evaluation_mappings", "study_groups"
add_foreign_key "structured_error_attributes", "error_template_attributes" add_foreign_key "structured_error_attributes", "error_template_attributes"
add_foreign_key "structured_error_attributes", "structured_errors" add_foreign_key "structured_error_attributes", "structured_errors"

View File

@ -25,7 +25,7 @@ describe FileParameters do
it 'new file' do it 'new file' do
submission = create(:submission, exercise: hello_world, id: 1337) submission = create(:submission, exercise: hello_world, id: 1337)
controller.instance_variable_set(:@current_user, submission.contributor) controller.instance_variable_set(:@current_contributor, submission.contributor)
new_file = create(:file, context: submission) new_file = create(:file, context: submission)
expect(file_accepted?(new_file)).to be true expect(file_accepted?(new_file)).to be true
@ -61,7 +61,7 @@ describe FileParameters do
submission_learner1 = create(:submission, exercise: hello_world, contributor: learner1) submission_learner1 = create(:submission, exercise: hello_world, contributor: learner1)
_submission_learner2 = create(:submission, exercise: hello_world, contributor: learner2) _submission_learner2 = create(:submission, exercise: hello_world, contributor: learner2)
controller.instance_variable_set(:@current_user, learner2) controller.instance_variable_set(:@current_contributor, learner2)
other_submissions_file = create(:file, context: submission_learner1) other_submissions_file = create(:file, context: submission_learner1)
expect(file_accepted?(other_submissions_file)).to be false expect(file_accepted?(other_submissions_file)).to be false
end end

View File

@ -22,6 +22,7 @@ describe Lti do
expect(controller.session).to receive(:delete).with(:external_user_id) expect(controller.session).to receive(:delete).with(:external_user_id)
expect(controller.session).to receive(:delete).with(:study_group_id) expect(controller.session).to receive(:delete).with(:study_group_id)
expect(controller.session).to receive(:delete).with(:embed_options) expect(controller.session).to receive(:delete).with(:embed_options)
expect(controller.session).to receive(:delete).with(:pg_id)
controller.send(:clear_lti_session_data) controller.send(:clear_lti_session_data)
end end
end end

View File

@ -5,166 +5,243 @@ require 'rails_helper'
describe SubmissionsController do describe SubmissionsController do
render_views render_views
let(:submission) { create(:submission) } let(:exercise) { create(:math) }
let(:contributor) { create(:admin) } let(:submission) { create(:submission, exercise:, contributor:) }
before { allow(controller).to receive(:current_user).and_return(contributor) } shared_examples 'a regular user' do |record_not_found_status_code|
describe 'POST #create' do
describe 'POST #create' do before do
before do controller.request.accept = 'application/json'
controller.request.accept = 'application/json'
end
context 'with a valid submission' do
let(:exercise) { create(:hello_world) }
let(:perform_request) { proc { post :create, format: :json, params: {submission: attributes_for(:submission, exercise_id: exercise.id)} } }
before { perform_request.call }
expect_assigns(submission: Submission)
it 'creates the submission' do
expect { perform_request.call }.to change(Submission, :count).by(1)
end end
expect_json context 'with a valid submission' do
expect_http_status(:created) let(:exercise) { create(:hello_world) }
let(:perform_request) { proc { post :create, format: :json, params: {submission: attributes_for(:submission, exercise_id: exercise.id)} } }
before { perform_request.call }
expect_assigns(submission: Submission)
it 'creates the submission' do
expect { perform_request.call }.to change(Submission, :count).by(1)
end
expect_json
expect_http_status(:created)
end
context 'with an invalid submission' do
before { post :create, params: {submission: {}} }
expect_assigns(submission: Submission)
expect_json
expect_http_status(:unprocessable_entity)
end
end end
context 'with an invalid submission' do describe 'GET #download_file' do
before { post :create, params: {submission: {}} } context 'with an invalid filename' do
before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} }
expect_assigns(submission: Submission) expect_http_status(record_not_found_status_code)
expect_json end
expect_http_status(:unprocessable_entity)
end
end
describe 'GET #download_file' do context 'with a valid binary filename' do
context 'with an invalid filename' do let(:exercise) { create(:sql_select) }
before { get :download_file, params: {filename: SecureRandom.hex, id: submission.id, format: :json} } let(:submission) { create(:submission, exercise:, contributor:) }
expect_http_status(:not_found) before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} }
end
context 'with a valid binary filename' do context 'with a binary file' do
let(:submission) { create(:submission, exercise: create(:sql_select)) } let(:file) { submission.collect_files.detect {|file| file.name == 'exercise' && file.file_type.file_extension == '.sql' } }
before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} } expect_assigns(file: :file)
expect_assigns(submission: :submission)
expect_content_type('application/octet-stream')
expect_http_status(:ok)
context 'with a binary file' do it 'sets the correct filename' do
let(:file) { submission.collect_files.detect {|file| file.name == 'exercise' && file.file_type.file_extension == '.sql' } } expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"")
end
end
end
expect_assigns(file: :file) context 'with a valid filename' do
expect_assigns(submission: :submission) let(:exercise) { create(:audio_video) }
expect_content_type('application/octet-stream') let(:submission) { create(:submission, exercise:, contributor:) }
expect_http_status(:ok)
it 'sets the correct filename' do before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} }
expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"")
context 'with a binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
expect_assigns(file: :file)
expect_assigns(submission: :submission)
it 'sets the correct redirect' do
expect(response.location).to eq protected_upload_url(id: file, filename: file.filepath)
end
end
context 'with a non-binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } }
expect_assigns(file: :file)
expect_assigns(submission: :submission)
expect_content_type('application/octet-stream')
expect_http_status(:ok)
it 'sets the correct filename' do
expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"")
end
end end
end end
end end
context 'with a valid filename' do describe 'GET #render_file' do
let(:submission) { create(:submission, exercise: create(:audio_video)) } let(:file) { submission.files.first }
let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) }
let(:token) { Rack::Utils.parse_nested_query(URI.parse(signed_url).query)['token'] }
before { get :download_file, params: {filename: file.name_with_extension, id: submission.id} } context 'with an invalid filename' do
let(:filename) { SecureRandom.hex }
context 'with a binary file' do before { get :render_file, params: {filename:, id: submission.id, token:} }
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
expect_assigns(file: :file) expect_http_status(record_not_found_status_code)
expect_assigns(submission: :submission)
it 'sets the correct redirect' do
expect(response.location).to eq protected_upload_url(id: file, filename: file.filepath)
end
end end
context 'with a non-binary file' do context 'with a valid filename' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } } let(:exercise) { create(:audio_video) }
let(:submission) { create(:submission, exercise:, contributor:) }
let(:filename) { file.name_with_extension }
expect_assigns(file: :file) before { get :render_file, params: {filename:, id: submission.id, token:} }
expect_assigns(submission: :submission)
expect_content_type('application/octet-stream')
expect_http_status(:ok)
it 'sets the correct filename' do context 'with a binary file' do
expect(response.headers['Content-Disposition']).to include("attachment; filename=\"#{file.name_with_extension}\"") let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
let(:signed_url_video) { AuthenticatedUrlHelper.sign(render_protected_upload_url(id: file, filename: file.filepath), file) }
expect_assigns(file: :file)
expect_assigns(submission: :submission)
it 'sets the correct redirect' do
expect(response.location).to eq signed_url_video
end
end
context 'with a non-binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } }
expect_assigns(file: :file)
expect_assigns(submission: :submission)
expect_content_type('text/javascript')
expect_http_status(:ok)
it 'renders the file content' do
expect(response.body).to eq(file.content)
end
end end
end end
end end
end
describe 'GET #index' do describe 'GET #run' do
before do let(:file) { submission.collect_files.detect(&:main_file?) }
create_pair(:submission) let(:perform_request) { get :run, format: :json, params: {filename: file.filepath, id: submission.id} }
get :index
end
expect_assigns(submissions: Submission.all) context 'when no errors occur during execution' do
expect_http_status(:ok) before do
expect_template(:index) allow_any_instance_of(described_class).to receive(:hijack)
end allow_any_instance_of(described_class).to receive(:close_client_connection)
allow_any_instance_of(Submission).to receive(:run).and_return({})
describe 'GET #render_file' do allow_any_instance_of(described_class).to receive(:save_testrun_output)
let(:file) { submission.files.first } perform_request
let(:signed_url) { AuthenticatedUrlHelper.sign(render_submission_url(submission, filename), submission) }
let(:token) { Rack::Utils.parse_nested_query(URI.parse(signed_url).query)['token'] }
context 'with an invalid filename' do
let(:filename) { SecureRandom.hex }
before { get :render_file, params: {filename:, id: submission.id, token:} }
expect_http_status(:not_found)
end
context 'with a valid filename' do
let(:submission) { create(:submission, exercise: create(:audio_video)) }
let(:filename) { file.name_with_extension }
before { get :render_file, params: {filename:, id: submission.id, token:} }
context 'with a binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.mp4' } }
let(:signed_url_video) { AuthenticatedUrlHelper.sign(render_protected_upload_url(id: file, filename: file.filepath), file) }
expect_assigns(file: :file)
expect_assigns(submission: :submission)
it 'sets the correct redirect' do
expect(response.location).to eq signed_url_video
end end
end
context 'with a non-binary file' do
let(:file) { submission.collect_files.detect {|file| file.file_type.file_extension == '.js' } }
expect_assigns(file: :file)
expect_assigns(submission: :submission) expect_assigns(submission: :submission)
expect_content_type('text/javascript') expect_assigns(file: :file)
expect_http_status(:ok) expect_http_status(204)
it 'renders the file content' do
expect(response.body).to eq(file.content)
end
end end
end end
end
describe 'GET #run' do describe 'GET #score' do
let(:file) { submission.collect_files.detect(&:main_file?) } let(:perform_request) { proc { get :score, format: :json, params: {id: submission.id} } }
let(:perform_request) { get :run, format: :json, params: {filename: file.filepath, id: submission.id} }
context 'when no errors occur during execution' do
before do before do
allow_any_instance_of(described_class).to receive(:hijack) allow_any_instance_of(described_class).to receive(:hijack)
allow_any_instance_of(described_class).to receive(:close_client_connection) allow_any_instance_of(described_class).to receive(:kill_client_socket)
allow_any_instance_of(Submission).to receive(:run).and_return({}) perform_request.call
allow_any_instance_of(described_class).to receive(:save_testrun_output) end
perform_request
expect_assigns(submission: :submission)
expect_http_status(204)
end
describe 'GET #show' do
before { get :show, params: {id: submission.id} }
expect_assigns(submission: :submission)
expect_http_status(:ok)
expect_template(:show)
end
describe 'GET #show.json' do
# Render views requested in controller tests in order to get json responses
# https://github.com/rails/jbuilder/issues/32
render_views
before { get :show, params: {id: submission.id}, format: :json }
expect_assigns(submission: :submission)
expect_http_status(:ok)
%i[run test].each do |action|
describe "##{action}_url" do
let(:url) { response.parsed_body.with_indifferent_access.fetch("#{action}_url") }
it "starts like the #{action} path" do
filename = File.basename(__FILE__)
expect(url).to start_with(Rails.application.routes.url_helpers.send(:"#{action}_submission_path", submission, filename).sub(filename, ''))
end
it 'ends with a placeholder' do
expect(url).to end_with("#{Submission::FILENAME_URL_PLACEHOLDER}.json")
end
end
end
describe '#render_url' do
let(:supported_urls) { response.parsed_body.with_indifferent_access.fetch('render_url') }
let(:file) { submission.collect_files.detect(&:main_file?) }
let(:url) { supported_urls.find {|hash| hash[:filepath] == file.filepath }['url'] }
it 'starts like the render path' do
expect(url).to start_with(Rails.application.routes.url_helpers.render_submission_url(submission, file.filepath, host: request.host))
end
it 'includes a token' do
expect(url).to include '?token='
end
end
describe '#score_url' do
let(:url) { response.parsed_body.with_indifferent_access.fetch('score_url') }
it 'corresponds to the score path' do
expect(url).to eq(Rails.application.routes.url_helpers.score_submission_path(submission, format: :json))
end
end
end
describe 'GET #test' do
let(:file) { submission.collect_files.detect(&:teacher_defined_assessment?) }
let(:output) { {} }
before do
file.update(hidden: false)
allow_any_instance_of(described_class).to receive(:hijack)
allow_any_instance_of(described_class).to receive(:kill_client_socket)
get :test, params: {filename: "#{file.filepath}.json", id: submission.id}
end end
expect_assigns(submission: :submission) expect_assigns(submission: :submission)
@ -173,88 +250,57 @@ describe SubmissionsController do
end end
end end
describe 'GET #show' do shared_examples 'denies access for regular, non-admin users' do # rubocop:disable RSpec/SharedContext
before { get :show, params: {id: submission.id} } describe 'GET #index' do
before do
expect_assigns(submission: :submission) create_pair(:submission, contributor:, exercise:)
expect_http_status(:ok) get :index
expect_template(:show)
end
describe 'GET #show.json' do
# Render views requested in controller tests in order to get json responses
# https://github.com/rails/jbuilder/issues/32
render_views
before { get :show, params: {id: submission.id}, format: :json }
expect_assigns(submission: :submission)
expect_http_status(:ok)
%i[run test].each do |action|
describe "##{action}_url" do
let(:url) { response.parsed_body.with_indifferent_access.fetch("#{action}_url") }
it "starts like the #{action} path" do
filename = File.basename(__FILE__)
expect(url).to start_with(Rails.application.routes.url_helpers.send(:"#{action}_submission_path", submission, filename).sub(filename, ''))
end
it 'ends with a placeholder' do
expect(url).to end_with("#{Submission::FILENAME_URL_PLACEHOLDER}.json")
end
end
end
describe '#render_url' do
let(:supported_urls) { response.parsed_body.with_indifferent_access.fetch('render_url') }
let(:file) { submission.collect_files.detect(&:main_file?) }
let(:url) { supported_urls.find {|hash| hash[:filepath] == file.filepath }['url'] }
it 'starts like the render path' do
expect(url).to start_with(Rails.application.routes.url_helpers.render_submission_url(submission, file.filepath, host: request.host))
end end
it 'includes a token' do expect_redirect(:root)
expect(url).to include '?token='
end
end
describe '#score_url' do
let(:url) { response.parsed_body.with_indifferent_access.fetch('score_url') }
it 'corresponds to the score path' do
expect(url).to eq(Rails.application.routes.url_helpers.score_submission_path(submission, format: :json))
end
end end
end end
describe 'GET #score' do context 'with an admin user' do
let(:perform_request) { proc { get :score, format: :json, params: {id: submission.id} } } let(:contributor) { create(:admin) }
before { allow(controller).to receive(:current_user).and_return(contributor) }
describe 'GET #index' do
before do
create_pair(:submission, contributor:, exercise:)
get :index
end
expect_assigns(submissions: Submission.all)
expect_http_status(:ok)
expect_template(:index)
end
it_behaves_like 'a regular user', :not_found
end
context 'with a programming group' do
let(:group_author) { create(:external_user) }
let(:other_group_author) { create(:external_user) }
let(:contributor) { create(:programming_group, exercise:, users: [group_author, other_group_author]) }
before do before do
allow_any_instance_of(described_class).to receive(:hijack) allow(controller).to receive_messages(current_contributor: contributor, current_user: group_author)
allow_any_instance_of(described_class).to receive(:kill_client_socket)
perform_request.call
end end
expect_assigns(submission: :submission) it_behaves_like 'a regular user', :unauthorized
expect_http_status(204) it_behaves_like 'denies access for regular, non-admin users'
end end
describe 'GET #test' do context 'with a learner' do
let(:file) { submission.collect_files.detect(&:teacher_defined_assessment?) } let(:contributor) { create(:external_user) }
let(:output) { {} }
before do before do
file.update(hidden: false) allow(controller).to receive_messages(current_user: contributor)
allow_any_instance_of(described_class).to receive(:hijack)
allow_any_instance_of(described_class).to receive(:kill_client_socket)
get :test, params: {filename: "#{file.filepath}.json", id: submission.id}
end end
expect_assigns(submission: :submission) it_behaves_like 'a regular user', :unauthorized
expect_assigns(file: :file) it_behaves_like 'denies access for regular, non-admin users'
expect_http_status(204)
end end
end end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
FactoryBot.define do
factory :programming_group do
exercise factory: :math
after(:build) do |programming_group|
# Do not change anything if users were provided explicitly
next if programming_group.users.present?
programming_group.users = build_list(:external_user, 2)
end
end
end

View File

@ -10,6 +10,11 @@ FactoryBot.define do
submission.exercise.files.editable.visible.each do |file| submission.exercise.files.editable.visible.each do |file|
submission.add_file(content: file.content, file_id: file.id) submission.add_file(content: file.content, file_id: file.id)
end end
# Do not change anything if a study group was provided explicitly or user has no study groups
next if submission.study_group.present? || submission.users.first.study_groups.blank?
submission.update!(study_group: submission.users.first.study_groups.first)
end end
end end
end end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'
describe ProgrammingGroupPolicy do
subject(:policy) { described_class }
let(:programming_group) { build(:programming_group) }
%i[new? create?].each do |action|
permissions(action) do
it 'grants access to everyone' do
%i[external_user teacher admin].each do |factory_name|
expect(policy).to permit(create(factory_name), programming_group)
end
end
end
end
end

View File

@ -15,13 +15,23 @@ describe SubmissionPolicy do
%i[download_file? render_file? run? score? show? statistics? stop? test?].each do |action| %i[download_file? render_file? run? score? show? statistics? stop? test?].each do |action|
permissions(action) do permissions(action) do
let(:exercise) { build(:math) }
let(:group_author) { build(:external_user) }
let(:other_group_author) { build(:external_user) }
it 'grants access to admins' do it 'grants access to admins' do
expect(policy).to permit(build(:admin), Submission.new) expect(policy).to permit(build(:admin), Submission.new)
end end
it 'grants access to authors' do it 'grants access to authors' do
contributor = create(:external_user) contributor = create(:external_user)
expect(policy).to permit(contributor, build(:submission, exercise: Exercise.new, contributor:)) expect(policy).to permit(contributor, build(:submission, exercise:, contributor:))
end
it 'grants access to other authors of the programming group' do
contributor = build(:programming_group, exercise:, users: [group_author, other_group_author])
expect(policy).to permit(group_author, build(:submission, exercise:, contributor:))
expect(policy).to permit(other_group_author, build(:submission, exercise:, contributor:))
end end
end end
end end

View File

@ -6,9 +6,12 @@ describe 'exercises/implement.html.slim' do
let(:exercise) { create(:fibonacci) } let(:exercise) { create(:fibonacci) }
let(:files) { exercise.files.visible } let(:files) { exercise.files.visible }
let(:non_binary_files) { files.reject {|file| file.file_type.binary? } } let(:non_binary_files) { files.reject {|file| file.file_type.binary? } }
let(:user) { create(:admin) }
before do before do
allow(view).to receive(:current_user).and_return(create(:admin)) without_partial_double_verification do
allow(view).to receive_messages(current_user: user, current_contributor: user)
end
assign(:exercise, exercise) assign(:exercise, exercise)
assign(:files, files) assign(:files, files)
assign(:paths, []) assign(:paths, [])