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:

committed by
Sebastian Serth

parent
0234414bae
commit
319c3ab3b4
@ -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
|
||||||
|
@ -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 ||
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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'}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
54
app/controllers/programming_groups_controller.rb
Normal file
54
app/controllers/programming_groups_controller.rb
Normal 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
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
82
app/models/programming_group.rb
Normal file
82
app/models/programming_group.rb
Normal 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
|
15
app/models/programming_group_membership.rb
Normal file
15
app/models/programming_group_membership.rb
Normal 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
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
@ -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
|
||||||
|
11
app/policies/programming_group_policy.rb
Normal file
11
app/policies/programming_group_policy.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ProgrammingGroupPolicy < ApplicationPolicy
|
||||||
|
def new?
|
||||||
|
everyone
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
everyone
|
||||||
|
end
|
||||||
|
end
|
@ -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?
|
||||||
|
@ -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|
|
||||||
|
@ -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?
|
||||||
|
@ -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')}: "
|
span == "#{t('activerecord.attributes.submission.score')}: "
|
||||||
span.score
|
span.score
|
||||||
|
@ -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
|
||||||
|
7
app/views/programming_groups/_form.html.slim
Normal file
7
app/views/programming_groups/_form.html.slim
Normal 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)
|
14
app/views/programming_groups/new.html.slim
Normal file
14
app/views/programming_groups/new.html.slim
Normal 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))
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
11
db/migrate/20230710131250_create_programming_groups.rb
Normal file
11
db/migrate/20230710131250_create_programming_groups.rb
Normal 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
|
@ -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
|
19
db/schema.rb
19
db/schema.rb
@ -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"
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
14
spec/factories/programming_group.rb
Normal file
14
spec/factories/programming_group.rb
Normal 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
|
@ -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
|
||||||
|
19
spec/policies/programming_group_policy_spec.rb
Normal file
19
spec/policies/programming_group_policy_spec.rb
Normal 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
|
@ -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
|
||||||
|
@ -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, [])
|
||||||
|
Reference in New Issue
Block a user