diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..5008ddfcf Binary files /dev/null and b/.DS_Store differ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..ef45a4869 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "sqltools.connections": [ + { + "mysqlOptions": { + "authProtocol": "default", + "enableSsl": "Disabled" + }, + "previewLimit": 50, + "server": "0.0.0.0", + "port": 3307, + "driver": "MySQL", + "database": "reimplementation_development", + "username": "root", + "name": "devexpertiza" + } + ] +} \ No newline at end of file diff --git a/Dangerfile b/Dangerfile index 5e53f4afe..6fd640064 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,4 +1,7 @@ -# Dangerfile +# Helper to safely read files in UTF-8 and avoid "invalid byte sequence" errors +def safe_read(path) + File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) +end # --- PR Size Checks --- warn("Pull request is too big (more than 500 LoC).") if git.lines_of_code > 500 @@ -14,7 +17,7 @@ warn("Pull request has duplicated commit messages.") if duplicated_commits.any? # --- TODO/FIXME Checks --- todo_fixme = (git.modified_files + git.added_files).any? do |file| - File.exist?(file) && File.read(file).match?(/\b(TODO|FIXME)\b/i) + File.exist?(file) && safe_read(file).match?(/\b(TODO|FIXME)\b/i) end warn("Pull request contains TODO or FIXME comments.") if todo_fixme @@ -25,7 +28,6 @@ warn("Pull request includes temp, tmp, or cache files.") if temp_files # --- Missing Test Checks --- warn("There are no test changes in this PR.") if (git.modified_files + git.added_files).none? { |f| f.include?('spec/') || f.include?('test/') } - # --- .md File Changes --- md_changes = git.modified_files.any? { |file| file.end_with?('.md') } warn("Pull request modifies markdown files (*.md). Make sure you have a good reason.") if md_changes @@ -49,11 +51,9 @@ config_files = %w[ changed_config_files = git.modified_files.select { |file| config_files.include?(file) } warn("Pull request modifies config or setup files: #{changed_config_files.join(', ')}.") if changed_config_files.any? - # --- Shallow Tests (RSpec) --- -# (Rules 37-41 — Shallow tests — assuming you want them included) shallow_test_files = git.modified_files.select { |file| file.include?('spec/') } shallow_test_warning = shallow_test_files.any? do |file| - File.exist?(file) && File.read(file).match?(/\bit\b|\bspecify\b/) + File.exist?(file) && safe_read(file).match?(/\bit\b|\bspecify\b/) end warn("RSpec tests seem shallow (single `it` blocks or no context). Consider improving test structure.") if shallow_test_warning diff --git a/Gemfile b/Gemfile index 020dbe491..aa91a954a 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '3.4.5' gem 'mysql2', '~> 0.5.7' gem 'sqlite3', '~> 1.4' # Alternative for development -gem 'puma', '~> 5.0' +gem 'puma', '~> 6.4' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 diff --git a/Gemfile.lock b/Gemfile.lock index 5e7341cb0..1570d2590 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,7 @@ GEM term-ansicolor thor crass (1.0.6) + csv (3.3.5) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) @@ -128,6 +129,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -149,8 +151,11 @@ GEM faraday (>= 0.8) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.3.2) + faraday (~> 2.0) find_with_order (1.3.1) activerecord (>= 3) + forwardable (1.3.3) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) @@ -197,10 +202,13 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (5.25.5) mize (0.6.1) + monitor (0.2.0) msgpack (1.8.0) multi_json (1.17.0) + mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) @@ -217,7 +225,7 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.2-aarch64-linux) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) @@ -230,6 +238,7 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -350,6 +359,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + set (1.1.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (0.22.0) @@ -358,7 +368,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + singleton (0.3.0) spring (4.4.0) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) stringio (3.1.7) sync (0.5.0) term-ansicolor (1.11.3) @@ -392,25 +405,38 @@ PLATFORMS arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 + arm64-darwin-25 x64-mingw-ucrt x86_64-linux DEPENDENCIES active_model_serializers (~> 0.10.0) bcrypt (~> 3.1.7) + bigdecimal bootsnap (>= 1.18.4) coveralls + csv danger database_cleaner-active_record + date debug + delegate factory_bot_rails faker + faraday-retry find_with_order + forwardable jwt (~> 2.7, >= 2.7.1) lingua - mysql2 (~> 0.5.5) + logger + mini_portile2 (~> 2.8) + monitor + mutex_m + mysql2 (~> 0.5.7) observer - puma (~> 6.0) + ostruct + psych (~> 5.2) + puma (~> 6.4) rack-cors rails (~> 8.0, >= 8.0.1) rspec-rails @@ -418,14 +444,19 @@ DEPENDENCIES rswag-specs rswag-ui rubocop + set shoulda-matchers simplecov simplecov_json_formatter + singleton spring + sqlite3 (~> 1.4) + timeout tzinfo-data + uri RUBY VERSION - ruby 3.2.7p253 + ruby 3.4.5p51 BUNDLED WITH 2.4.14 diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index ae4f4bbdf..e4b7c333a 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -49,4 +49,221 @@ def has_role?(required_role) required_role = required_role.name if required_role.is_a?(Role) current_user&.role&.name == required_role end + + # Determine if the currently logged-in user has the privileges of a Super-Admin + def current_user_has_super_admin_privileges? + current_user_has_privileges_of?('Super Administrator') + end + + # Determine if the currently logged-in user has the privileges of an Admin (or higher) + def current_user_has_admin_privileges? + current_user_has_privileges_of?('Administrator') + end + + # Determine if the currently logged-in user has the privileges of an Instructor (or higher) + def current_user_has_instructor_privileges? + current_user_has_privileges_of?('Instructor') + end + + # Determine if the currently logged-in user has the privileges of a TA (or higher) + def current_user_has_ta_privileges? + current_user_has_privileges_of?('Teaching Assistant') + end + + # Determine if the currently logged-in user has the privileges of a Student (or higher) + def current_user_has_student_privileges? + current_user_has_privileges_of?('Student') + end + + # Determine if the currently logged-in user is participating in an Assignment based on the assignment_id argument + def current_user_is_assignment_participant?(assignment_id) + if user_logged_in? + return AssignmentParticipant.exists?(parent_id: assignment_id, user_id: current_user.id) + end + false + end + + def current_user_teaching_staff_of_assignment?(assignment_id) + assignment = Assignment.find(assignment_id) + user_logged_in? && + ( + current_user_instructs_assignment?(assignment) || + current_user_has_ta_mapping_for_assignment?(assignment) + ) + end + + # Determine if the currently logged-in user IS of the given role name + # If there is no currently logged-in user simply return false + # parameter role_name should be one of: 'Student', 'Teaching Assistant', 'Instructor', 'Administrator', 'Super-Administrator' + def current_user_is_a?(role_name) + current_user_and_role_exist? && current_user.role.name == role_name + end + + # Determine if the current user has the passed in id value + # parameter id can be integer or string + def current_user_has_id?(id) + user_logged_in? && current_user.id.eql?(id.to_i) + end + + # Determine if the currently logged-in user created the bookmark with the given ID + # If there is no currently logged-in user (or that user has no ID) simply return false + # Bookmark ID can be passed as string or number + # If the bookmark is not found, simply return false + def current_user_created_bookmark_id?(bookmark_id) + user_logged_in? && !bookmark_id.nil? && Bookmark.find(bookmark_id.to_i).user_id == current_user.id + rescue ActiveRecord::RecordNotFound + return false + end + + # Determine if the given user can submit work + def given_user_can_submit?(user_id) + given_user_can?(user_id, 'submit') + end + + # Determine if the given user can review work + def given_user_can_review?(user_id) + given_user_can?(user_id, 'review') + end + + # Determine if the given user can take quizzes + def given_user_can_take_quiz?(user_id) + given_user_can?(user_id, 'take_quiz') + end + + # Determine if the given user can read work + def given_user_can_read?(user_id) + # Note that the ability to read is in the model as can_take_quiz + # Per Dr. Gehringer, "I believe that 'can_take_quiz' means that the participant is a reader, + # but please check the code to verify". + # This was verified in the Participant model + given_user_can_take_quiz?(user_id) + end + + def response_edit_allowed?(map, user_id) + assignment = map.reviewer.assignment + # if it is a review response map, all the members of reviewee team should be able to view the response (can be done from heat map) + if map.is_a? ReviewResponseMap + reviewee_team = AssignmentTeam.find(map.reviewee_id) + return user_logged_in? && + ( + current_user_has_id?(user_id) || + reviewee_team.user?(current_user) || + current_user_has_admin_privileges? || + (current_user_is_a?('Instructor') && current_user_instructs_assignment?(assignment)) || + (current_user_is_a?('Teaching Assistant') && current_user_has_ta_mapping_for_assignment?(assignment)) + ) + end + current_user_has_id?(user_id) || + (current_user_is_a?('Instructor') && current_user_instructs_assignment?(assignment)) || + (assignment.course && current_user_is_a?('Teaching Assistant') && current_user_has_ta_mapping_for_assignment?(assignment)) + end + + # Determine if there is a current user + # The application controller method current_user + # will return a user even if current_user has been explicitly cleared out + # because it is "sticky" in that it uses "@current_user ||= current_user" + # So, this method can be used to answer a controller's question + # "is anyone CURRENTLY logged in" + def user_logged_in? + !current_user.nil? + end + + # Determine if the currently logged-in user is an ancestor of the passed in user + def current_user_ancestor_of?(user) + return current_user.recursively_parent_of(user) if user_logged_in? && user + false + end + + # Recursively find an assignment for a given Response id. Because a ResponseMap + # Determine if the current user is an instructor for the given assignment + def current_user_instructs_assignment?(assignment) + user_logged_in? && !assignment.nil? && ( + assignment.instructor_id == current_user.id || + (assignment.course_id && Course.find(assignment.course_id).instructor_id == current_user.id) + ) + end + + # Determine if the current user and the given assignment are associated by a TA mapping + def current_user_has_ta_mapping_for_assignment?(assignment) + user_logged_in? && !assignment.nil? && TaMapping.exists?(user_id: current_user.id, course_id: assignment.course.id) + end + + # Recursively find an assignment given the passed in Response id. Because a ResponseMap + # can either point to an Assignment or another Response, recursively search until the + # ResponseMap object's reviewed_object_id points to an Assignment. + def find_assignment_from_response_id(response_id) + response = Response.find(response_id.to_i) + response_map = response.response_map + if response_map.assignment + return response_map.assignment + else + find_assignment_from_response_id(response_map.reviewed_object_id) + end + end + + # Finds the assignment_instructor for a given assignment. If the assignment is associated with + # a course, the instructor for the course is returned. If not, the instructor associated + # with the assignment is return. + def find_assignment_instructor(assignment) + if assignment.course + Course.find_by(id: assignment.course.id).instructor + else + assignment.instructor + end + end + + def current_user_has_all_heatgrid_data_privileges?(assignment) + return false unless user_logged_in? + + # 1. Super Admin + return true if current_user_is_a?('Super Administrator') + + # 2. Admin who created the instructor of the assignment + if current_user_is_a?('Administrator') + instructor = find_assignment_instructor(assignment) + return true if instructor && instructor.parent_id == current_user.id + end + + # 3. Instructor of the assignment + return true if current_user_is_a?('Instructor') && current_user_instructs_assignment?(assignment) + + # 4. TA mapped to the course of the assignment + return true if current_user_is_a?('Teaching Assistant') && current_user_has_ta_mapping_for_assignment?(assignment) + + false + end + + # PRIVATE METHODS + private + + # Determine if the currently logged-in user has the privileges of the given role name (or higher privileges) + # Let the Role model define this logic for the sake of DRY + # If there is no currently logged-in user simply return false + def current_user_has_privileges_of?(role_name) + # puts current_user_and_role_exist? + # puts current_user + # puts current_user.role.all_privileges_of?(Role.find_by(name: role_name)) + current_user_and_role_exist? && current_user.role.all_privileges_of?(Role.find_by(name: role_name)) + end + + # Determine if the given user is a participant of some kind + # who is allowed to perform the given action ("submit", "review", "take_quiz") + def given_user_can?(user_id, action) + participant = Participant.find_by(id: user_id) + return false if participant.nil? + case action + when 'submit' + participant.can_submit + when 'review' + participant.can_review + when 'take_quiz' + participant.can_take_quiz + else + raise "Did not recognize user action '" + action + "'" + end + end + + def current_user_and_role_exist? + user_logged_in? && !current_user.role.nil? + end end \ No newline at end of file diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb new file mode 100644 index 000000000..287a64996 --- /dev/null +++ b/app/controllers/grades_controller.rb @@ -0,0 +1,278 @@ +class GradesController < ApplicationController + include GradesHelper + + def action_allowed? + case params[:action] + when 'view_our_scores','view_my_scores' + set_participant_and_team_via_assignment + current_user_is_assignment_participant?(params[:assignment_id]) + when 'view_all_scores' + current_user_teaching_staff_of_assignment?(params[:assignment_id]) + when 'edit', 'assign_grade', 'instructor_review' + set_team_and_assignment_via_participant + current_user_instructs_assignment?(@assignment) + else + render json: { error: "You do not have permission to perform this action." }, status: :forbidden + end + end + + # index (GET /api/v1/grades/:assignment_id/view_all_scores) + # returns all review scores and computed heatmap data for the given assignment (instructor/TA view). + def view_all_scores + @assignment = Assignment.find(params[:assignment_id]) + participant_scores = [] + team_scores = [] + + @assignment.participants.each do |participant| + participant_scores.push(get_my_scores_data(participant)) + end + + @assignment.teams.each do |team| + team_scores.push(get_our_scores_data(team)) + end + + render json: { + team_scores: team_scores, + participant_scores: participant_scores + } + end + + + # view_our_scores (GET /api/v1/grades/:assignment_id/view_our_scores) + # similar to view but scoped to the requesting student’s own team. + # It returns the same heatmap data with reviewer identities removed, plus the list of review items. + # renders JSON with scores, assignment, averages. + # This meets the student’s need to see heatgrids for their team only (with anonymous reviewers) and the associated items. + def view_our_scores + render json: get_our_scores_data(@team) + end + + # (GET /api/v1/grades/:assignment_id/view_my_scores) + # similar to view but scoped to the requesting student’s own scores given by its teammates and also . + def view_my_scores + render json: get_my_scores_data(@participant) + end + + + # edit (GET /api/v1/grades/:participant_id/edit) + # provides data for the grade-assignment interface. + # Given an AssignmentParticipant ID, it looks up the participant and its assignment, gathers the full list of items + # (via a helper like list_questions(assignment)), and computes existing peer-review scores for those items. + # It then returns JSON including the participant, assignment, items, and current scores. + # This lets the front end display an interface where an instructor can assign a grade and feedback (score breakdown) to that submission. + def edit + items = list_items(@assignment) + scores = {} + scores[:my_team] = get_our_scores_data(@team) + scores[:my_own] = get_my_scores_data(@participant) + render json: { + participant: @participant, + assignment: @assignment, + items: items, + scores: scores + } + end + + + # assign_grade (PATCH /api/v1/grades/:participant_id/assign_grade) + # saves an instructor’s grade and feedback for a team submission. + # The method sets team.grade_for_submission and team.comment_for_submission. + # This implements “assign score & give feedback” functionality for instructor. + def assign_grade + # team = @participant.team + @team.grade_for_submission = params[:grade_for_submission] + @team.comment_for_submission = params[:comment_for_submission] + if @team.save + render json: { message: "Grade and comment assigned to team #{@team.name} successfully." }, status: :ok + else + render json: { error: "Failed to assign grade or comment to team #{@team.name}." }, status: :unprocessable_entity + end + end + + + # instructor_review (GET /api/v1/grades/:participant_id/instructor_review) + # helps the instructor begin grading or re-grading a submission. + # It finds or creates the appropriate review mapping for the given participant and returns JSON indicating whether to go to + # Response#new (no review exists yet) or Response#edit (review already exists). + # This supports the instructor’s ability to open or edit a review for a student’s submission. + def instructor_review + reviewer = AssignmentParticipant.find_or_create_by!(user_id: current_user.id, parent_id: @assignment.id, handle: current_user.name) + + mapping = ReviewResponseMap.find_or_create_by!( + reviewed_object_id: @assignment.id, + reviewer_id: reviewer.id, + reviewee_id: @team.id + ) + + existing_response = Response.find_by(map_id: mapping.id) + action = existing_response.present? ? 'edit' : 'new' + + render json: { + map_id: mapping.id, + response_id: existing_response&.id, + redirect_to: "/response/#{action}/#{mapping.id}" + } + end + + private + + # helper method used when participant_id is passed as a paramater. this will be helpful in case of instructor/TA view + # as they need participant id to view their scores or assign grade. It will take the participant id (i.e. AssignmentParticipant ID) to set + # the team and assignment variables which are used inside other methods like edit, update, assign_grade + def set_team_and_assignment_via_participant + @participant = AssignmentParticipant.find(params[:participant_id]) + unless @participant + return { error: 'Participant not found for this assignment' , status: :not_found} + end + @team = @participant.team + unless @team + return { error: 'Team not found for this assignment' , status: :not_found} + end + @assignment = @participant.assignment + end + + # helper method used when participant_id is passed as a paramater. this will be helpful in case of student view + # It will take the assignment id and the current user's id to set the participant and team variables which are used inside other methods + # like view_our_scores and view_my_scores + def set_participant_and_team_via_assignment + @participant = AssignmentParticipant.find_by(parent_id: params[:assignment_id], user_id: current_user.id) + unless @participant + return { error: 'Participant not found' , status: :not_found} + end + @team = @participant.team + unless @team + return { error: 'Team not found' , status: :not_found} + end + @assignment = @participant.assignment + end + + + # returns the heatgrid data required for a team to view their scores and average score of their work for an assignment + def get_our_scores_data(team) + reviews_of_our_work_maps = ReviewResponseMap.where(reviewed_object_id: @assignment.id, reviewee_id: team.id).to_a + reviews_of_our_work = get_heatgrid_data_for(reviews_of_our_work_maps) + avg_score_of_our_work = team.aggregate_review_grade + + { + team_details: team, + reviews_of_our_work: reviews_of_our_work, + avg_score_of_our_work: avg_score_of_our_work + } + end + + # returns the heatgrid data required for a participant to view their scores and average score of their work for an assignment + # the data includes the scores given by their teammates as well as the scores given by the authors the participant reviewed + def get_my_scores_data(participant) + # the set of review maps that my team members used to review me + reviews_of_me_maps = TeammateReviewResponseMap.where(reviewed_object_id: @assignment.id, reviewee_id: participant.id).to_a + + # the set of review maps that I used to review my team members + reviews_by_me_maps = TeammateReviewResponseMap.where(reviewed_object_id: @assignment.id, reviewer_id: participant.id).to_a + + reviews_of_me = get_heatgrid_data_for(reviews_of_me_maps) + + reviews_by_me = get_heatgrid_data_for(reviews_by_me_maps) + + # Fetch all review response maps where the current participant is the reviewer and the reviewed object is the current assignment. + my_reviews_of_other_teams_maps = ReviewResponseMap.where(reviewed_object_id: @assignment.id, reviewer_id: participant.id) + + # the maps that the authors I (the participant) reviewed used to give feedback on my reviews + feedback_from_my_reviewees_maps = [] + + # Map each review to its corresponding FeedbackResponseMap, may return nil if not found + # Then remove all nil entries using .compact before adding them to the main array + feedback_from_my_reviewees_maps += my_reviews_of_other_teams_maps.map do |map| + FeedbackResponseMap.find_by(reviewed_object_id: map.id, reviewee_id: participant.id) + end.compact + + feedback_scores_from_my_reviewees = get_heatgrid_data_for(feedback_from_my_reviewees_maps) + + avg_score_from_my_teammates = participant.aggregate_teammate_review_grade(reviews_of_me_maps) + avg_score_to_my_teammates = participant.aggregate_teammate_review_grade(reviews_by_me_maps) + avg_score_from_my_authors = participant.aggregate_teammate_review_grade(feedback_from_my_reviewees_maps) + + { + participant_details: participant, + reviews_of_me: reviews_of_me, + reviews_by_me: reviews_by_me, + author_feedback_scores: feedback_scores_from_my_reviewees, + avg_score_from_my_teammates: avg_score_from_my_teammates, + avg_score_to_my_teammates: avg_score_to_my_teammates, + avg_score_from_my_authors: avg_score_from_my_authors + } + end + + # it returns the heatgrid data for a collection of maps (ReviewResponseMap/FeedbackResponseMap/TeammateReviewResponseMap) + def get_heatgrid_data_for(maps) + # Initialize a hash to store scores grouped by review rounds + reviewee_scores = {} + return if maps.empty? + + # check if the assignment uses different rubrics for each round + if @assignment.varying_rubrics_by_round? + # Retrieve all round numbers that have distinct questionnaires + rounds = @assignment.review_rounds(maps.first.questionnaire_type) + + rounds.each do |round| + # Create a symbol like :Review-Round-1 or :TeammateReview-Round-2 + round_symbol = ("#{maps.first.questionnaire_type}-Round-#{round}").to_sym + + # Initialize the array to hold scores for the current round + reviewee_scores[round_symbol] = [] + + # Go through each response map (i.e., reviewer mapping) + maps.each_with_index do |map, index| + # Find the most recent submitted response for the current round + submitted_round_response = map.responses.select do |r| + r.round == round && r.is_submitted && r.map_id == map.id + end.last + + # Skip if no valid response was submitted + next if submitted_round_response.nil? + + # Go through each score in the submitted response + submitted_round_response.scores.each_with_index do |score, newIndex| + # Initialize sub-array if it doesn't exist + reviewee_scores[round_symbol][newIndex] ||= [] + + # Add the score's answer, optionally anonymizing reviewer name + reviewee_scores[round_symbol][newIndex] << get_answer(score, index) + end + end + + reviewee_scores[round_symbol].each_with_index do |scores_array, idx| + # Sort each question's answers array by reviewer_name and reviwee_name + reviewee_scores[round_symbol][idx] = scores_array.sort_by { |answer| [answer[:reviewer_name].downcase , answer[:reviewee_name].downcase] } + end + end + + end + + # Return the organized hash of scores grouped by round + return reviewee_scores + end + + def get_answer(score, index) + # Determine the name or label to show for the reviewer + reviewer_name = if current_user_has_all_heatgrid_data_privileges?(@assignment) + score&.response&.reviewer&.fullname # Show the actual reviewer's name + else + "Participant #{index+1}" # Show generic label (e.g., Participant 1) + end + + # useful in case of reviews done by reviews_by_me (reviews given by a user to its teammates) + # in that case we will need reviewee's name instead of reviewer name because the reviewer will be the user itself. + reviewee_name = score&.response&.reviewee&.fullname + + #Return particular score/answer information + return { + id: score.id, + item_id:score.item_id, + txt: score.item.txt, + answer:score.answer, + comments:score.comments, + reviewer_name: reviewer_name, + reviewee_name: reviewee_name + } + end +end \ No newline at end of file diff --git a/app/controllers/participants_controller.rb b/app/controllers/participants_controller.rb index 97f3241b0..878209621 100644 --- a/app/controllers/participants_controller.rb +++ b/app/controllers/participants_controller.rb @@ -29,7 +29,7 @@ def list_assignment_participants if participants.nil? render json: participants.errors, status: :unprocessable_entity else - render json: participants, status: :ok + render json: participants.as_json(include: { user: { include: %i[role parent] } }), status: :ok end end @@ -103,7 +103,7 @@ def update_authorization # DELETE /participants/:id def destroy participant = Participant.find_by(id: params[:id]) - + if participant.nil? render json: { error: 'Not Found' }, status: :not_found elsif participant.destroy @@ -139,8 +139,7 @@ def filter_user_participants(user) # Filters participants based on the provided assignment # Returns participants ordered by their IDs def filter_assignment_participants(assignment) - participants = Participant.all - participants = participants.where(parent_id: assignment.id, type: 'AssignmentParticipant') if assignment + participants = Participant.where(parent_id: assignment.id, type: 'AssignmentParticipant') if assignment participants.order(:id) end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 56599cd83..a24cd23f2 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -24,7 +24,6 @@ def show # Creates a new team associated with the current user def create @team = Team.new(team_params) - @team.user = current_user if @team.save render json: @team, serializer: TeamSerializer, status: :created else @@ -95,7 +94,7 @@ def set_team # Whitelists the parameters allowed for team creation/updation def team_params - params.require(:team).permit(:name, :max_team_size, :type, :assignment_id) + params.require(:team).permit(:name, :type, :assignment_id) end # Whitelists parameters required to add a team member diff --git a/app/helpers/grades_helper.rb b/app/helpers/grades_helper.rb new file mode 100644 index 000000000..45d2a784f --- /dev/null +++ b/app/helpers/grades_helper.rb @@ -0,0 +1,318 @@ +module GradesHelper + include PenaltyHelper + + # Calculates and applies penalties for participants of a given assignment. + def penalties(assignment_id) + assignment = Assignment.find(assignment_id) + calculate_for_participants = should_calculate_penalties?(assignment) + + all_penalties = {} + + Participant.where(assignment_id: assignment_id).each do |participant| + penalties = calculate_penalty(participant.id) + total_penalty = calculate_total_penalty(penalties) + + if total_penalty > 0 + total_penalty = apply_max_penalty(total_penalty) + attributes(participant) if calculate_for_participants + end + + all_penalties = assign_all_penalties(participant, penalties) + end + + mark_penalty_as_calculated(assignment) unless assignment.is_penalty_calculated + return all_penalties + end + + # Calculates and applies penalties for the current assignment. + def update_penalties(assignment) + penalties(assignment.id) + end + + # Retrieves the name of the current user's role, if available. + def current_role_name + current_role.try :name + end + + # Retrieves items from the given questionnaires for the specified assignment, considering the round if applicable. + def retrieve_items(questionnaires, assignment_id) + items = {} + questionnaires.each do |questionnaire| + round = AssignmentQuestionnaire.where(assignment_id: assignment_id, questionnaire_id: questionnaire.id).first.used_in_round + #can accommodate other types of questionnaires too such as TeammateReviewQuestionnaire, AuthorFeedbackQuestionnaire + questionnaire_symbol = if round.nil? + questionnaire.display_type + else + (questionnaire.display_type.to_s + '-Round-' + round.to_s).to_sym + end + # questionnaire_symbol = questionnaire.id + items[questionnaire_symbol] = questionnaire.items + end + items + end + + # Retrieves the participant and their associated assignment data. + def fetch_participant_and_assignment(id) + @participant = AssignmentParticipant.find(id) + @assignment = @participant.assignment + end + + # Retrieves the questionnaires and their associated items for the assignment. + def fetch_questionnaires_and_items(assignment) + questionnaires = assignment.questionnaires + items = retrieve_items(questionnaires, assignment.id) + return items + end + + # Fetches the scores for the participant based on the retrieved items. + def fetch_participant_scores(participant, items) + pscore = Response.participant_scores(participant, items) + return pscore + end + + + # Summarizes the feedback received by the reviewee, including overall summary and average scores by round and criterion. + def fetch_feedback_summary + summary_ws_url = WEBSERVICE_CONFIG['summary_webservice_url'] + sum = SummaryHelper::Summary.new.summarize_reviews_by_reviewee(@items, @assignment, @team_id, summary_ws_url, session) + @summary = sum.summary + @avg_scores_by_round = sum.avg_scores_by_round + @avg_scores_by_criterion = sum.avg_scores_by_criterion + end + + # Processes questionnaires for a team, considering topic-specific and round-specific rubrics, and populates view models accordingly. + def process_questionare_for_team(assignment, team_id, questionnaires, team, participant) + vmlist = [] + + counter_for_same_rubric = 0 + # if @assignment.vary_by_topic? + # topic_id = SignedUpTeam.topic_id_by_team_id(@team_id) + # topic_specific_questionnaire = AssignmentQuestionnaire.where(assignment_id: @assignment.id, topic_id: topic_id).first.questionnaire + # @vmlist << populate_view_model(topic_specific_questionnaire) + # end + + questionnaires.each do |questionnaire| + round = nil + + # Guard clause to skip questionnaires that have already been populated for topic specific reviewing + # if @assignment.vary_by_topic? && questionnaire.type == 'ReviewQuestionnaire' + # next # Assignments with topic specific rubrics cannot have multiple rounds of review + # end + + if assignment.varying_rubrics_by_round? && questionnaire.questionnaire_type == 'ReviewQuestionnaire' + questionnaires = AssignmentQuestionnaire.where(assignment_id: assignment.id, questionnaire_id: questionnaire.id) + if questionnaires.count > 1 + round = questionnaires[counter_for_same_rubric].used_in_round + counter_for_same_rubric += 1 + else + round = questionnaires[0].used_in_round + counter_for_same_rubric = 0 + end + end + vmlist << populate_view_model(questionnaire, assignment, round, team, participant) + end + return vmlist + end + + # Redirects the user if they are not allowed to access the assignment, based on team or reviewer authorization. + def redirect_when_disallowed(participant) + if is_team_assignment?(participant) + redirect_if_not_on_correct_team(participant) + else + redirect_if_not_authorized_reviewer(participant) + end + false + end + + # Populates the view model with questionnaire data, team members, reviews, and calculated metrics. + def populate_view_model(questionnaire, assignment, round, team, participant) + vm = VmQuestionResponse.new(questionnaire, assignment, round) + vmitems = questionnaire.items + vm.add_items(vmitems) + vm.add_team_members(team) + qn = AssignmentQuestionnaire.where(assignment_id: assignment.id, used_in_round: 2).size >= 1 + vm.add_reviews(participant, team, assignment.varying_rubrics_by_round?) + vm.calculate_metrics + vm + end + + # Finds an assignment participant by ID, and handles the case where the participant is not found. + def find_participant(participant_id) + AssignmentParticipant.find(participant_id) + rescue ActiveRecord::RecordNotFound + flash[:error] = "Assignment participant #{participant_id} not found" + nil + end + + # Finds an assignment participant by ID, and handles the case where the participant is not found. + def find_assignment(assignment_id) + Assignment.find(assignment_id) + rescue ActiveRecord::RecordNotFound + flash[:error] = "Assignment participant #{assignment_id} not found" + nil + end + + # Checks if the student has the necessary permissions and authorizations to proceed. + def student_with_permissions? + has_role?('Student') && + self_review_finished?(current_user.id) && + are_needed_authorizations_present?(current_user.id, 'reader', 'reviewer') + end + + # Checks if the user is either a student viewing their own team or has Teaching Assistant privileges. + def student_or_ta? + student_viewing_own_team? || has_privileges_of?('Teaching Assistant') + end + + # This method checks if the current user, who must have the 'Student' role, is viewing their own team. + def student_viewing_own_team? + return false unless has_role?('Student') + + participant = AssignmentParticipant.find_by(id: params[:id]) + participant && current_user_is_assignment_participant?(participant.assignment.id) + end + + # Check if the self-review for the participant is finished based on assignment settings and submission status. + def self_review_finished?(id) + participant = Participant.find(id) + assignment = participant.try(:assignment) + self_review_enabled = assignment.try(:is_selfreview_enabled) + not_submitted = ResponseMap.self_review_pending?(participant.try(:id)) + puts self_review_enabled + if self_review_enabled + !not_submitted + else + true + end + end + + + # Methods associated with View methods: + # Determines if the rubric changes by round and returns the corresponding items based on the criteria. + def filter_questionnaires(assignment) + questionnaires = assignment.questionnaires + if assignment.varying_rubrics_by_round? + retrieve_items(questionnaires, assignment.id) + else + items = {} + questionnaires.each do |questionnaire| + items[questionnaire.id.to_s.to_sym] = questionnaire.items + end + items + end + end + + + # This method retrieves all items from relevant questionnaires associated with this assignment. + def list_items(assignment) + assignment.questionnaires.each_with_object({}) do |questionnaire, items| + items[questionnaire.id.to_s] = questionnaire.items + end + end + + # Method associated with Update methods: + # Displays an error message if the participant is not found. + def handle_not_found + render json: { error: 'Participant not found.' }, status: :not_found + end + + # Checks if the participant's grade has changed compared to the new grade. + def grade_changed?(participant, new_grade) + return false if new_grade.nil? + + format('%.2f', params[:total_score]) != new_grade + end + + # Generates a message based on whether the participant's grade is present or computed. + def grade_message(participant) + participant.grade.nil? ? "The computed score will be used for #{participant.user.name}." : + "A score of #{participant.grade}% has been saved for #{participant.user.name}." + end + + + # Methods associated with instructor_review: + # Finds or creates a reviewer for the given user and assignment, and sets a handle if it's a new record + def find_or_create_reviewer(user_id, assignment_id) + reviewer = AssignmentParticipant.find_or_create_by(user_id: user_id, parent_id: assignment_id) + reviewer.set_handle if reviewer.new_record? + reviewer + end + + # Finds or creates a review mapping between the reviewee and reviewer for the given assignment. + def find_or_create_review_mapping(reviewee_id, reviewer_id, assignment_id) + ReviewResponseMap.find_or_create_by(reviewee_id: reviewee_id, reviewer_id: reviewer_id, reviewed_object_id: assignment_id) + end + + # Redirects to the appropriate review page based on whether the review mapping is new or existing. + def redirect_to_review(review_mapping) + if review_mapping.new_record? + redirect_to controller: 'response', action: 'new', id: review_mapping.map_id, return: 'instructor' + else + review = Response.find_by(map_id: review_mapping.map_id) + redirect_to controller: 'response', action: 'edit', id: review.id, return: 'instructor' + end + end + + private + + # Determines if penalties should be calculated based on the assignment's penalty status. + def should_calculate_penalties?(assignment) + !assignment.is_penalty_calculated + end + + # Calculates the total penalty from submission, review, and meta-review penalties. + def calculate_total_penalty(penalties) + total = penalties[:submission] + penalties[:review] + penalties[:meta_review] + total > 0 ? total : 0 + end + + # Applies the maximum penalty limit based on the assignment's late policy. + def apply_max_penalty(total_penalty) + late_policy = LatePolicy.find(@assignment.late_policy_id) + total_penalty > late_policy.max_penalty ? late_policy.max_penalty : total_penalty + end + + # Marks the assignment's penalty status as calculated. + def mark_penalty_as_calculated(assignment) + assignment.update(is_penalty_calculated: true) + end + + def assign_all_penalties(participant, penalties) + all_penalties[participant.id] = { + submission: penalties[:submission], + review: penalties[:review], + meta_review: penalties[:meta_review], + total_penalty: @total_penalty + } + return all_penalties + end + + # Checks if the assignment is a team assignment based on the maximum team size. + def is_team_assignment?(participant) + participant.assignment.max_team_size > 1 + end + + # Redirects the user if they are not on the correct team that provided the feedback. + def redirect_if_not_on_correct_team(participant) + team = participant.team + puts team.attributes + if team.nil? || !team.user?(session[:user]) + flash[:error] = 'You are not on the team that wrote this feedback' + redirect_to '/' + end + end + + # Redirects the user if they are not an authorized reviewer for the feedback. + def redirect_if_not_authorized_reviewer(participant) + reviewer = AssignmentParticipant.where(user_id: session[:user].id, parent_id: participant.assignment.id).first + return if current_user_id?(reviewer.try(:user_id)) + + flash[:error] = 'You are not authorized to view this feedback' + redirect_to '/' + end + + # def get_penalty_from_helper(participant_id) + # get_penalty(participant_id) + # end + +end \ No newline at end of file diff --git a/app/helpers/metric_helper.rb b/app/helpers/metric_helper.rb index 07019365a..f01235380 100644 --- a/app/helpers/metric_helper.rb +++ b/app/helpers/metric_helper.rb @@ -37,7 +37,7 @@ def get_all_review_comments(reviewer_id) (1..num_review_rounds + 1).each do |round| comments_in_round[round] = '' counter_in_round[round] = 0 - last_response_in_current_round = response_map.response.select { |r| r.round == round }.last + last_response_in_current_round = response_map.responses.select { |r| r.round == round }.last next if last_response_in_current_round.nil? last_response_in_current_round.scores.each do |answer| diff --git a/app/helpers/penalty_helper.rb b/app/helpers/penalty_helper.rb new file mode 100644 index 000000000..443ecdd46 --- /dev/null +++ b/app/helpers/penalty_helper.rb @@ -0,0 +1,135 @@ +module PenaltyHelper + def get_penalty(participant_id) + set_participant_and_assignment(participant_id) + set_late_policy if @assignment.late_policy_id + + penalties = { submission: 0, review: 0, meta_review: 0 } + penalties[:submission] = calculate_submission_penalty + penalties[:review] = calculate_review_penalty + penalties[:meta_review] = calculate_meta_review_penalty + penalties + end + + def set_participant_and_assignment(participant_id) + @participant = AssignmentParticipant.find(participant_id) + @assignment = @participant.assignment + end + + def set_late_policy + late_policy = LatePolicy.find(@assignment.late_policy_id) + @penalty_per_unit = late_policy.penalty_per_unit + @max_penalty_for_no_submission = late_policy.max_penalty + @penalty_unit = late_policy.penalty_unit + end + + def calculate_submission_penalty + return 0 if @penalty_per_unit.nil? + + submission_due_date = get_submission_due_date + submission_records = SubmissionRecord.where(team_id: @participant.team.id, assignment_id: @participant.assignment.id) + late_submission_times = get_late_submission_times(submission_records, submission_due_date) + + if late_submission_times.any? + calculate_late_submission_penalty(late_submission_times.last.updated_at, submission_due_date) + else + handle_no_submission(submission_records) + end + end + + def get_submission_due_date + AssignmentDueDate.where(deadline_type_id: @submission_deadline_type_id, parent_id: @assignment.id).first.due_at + end + + def get_late_submission_times(submission_records, submission_due_date) + submission_records.select { |submission_record| submission_record.updated_at > submission_due_date } + end + + def calculate_late_submission_penalty(last_submission_time, submission_due_date) + return 0 if last_submission_time <= submission_due_date + + time_difference = last_submission_time - submission_due_date + penalty_units = calculate_penalty_units(time_difference, @penalty_unit) + penalty_for_submission = penalty_units * @penalty_per_unit + apply_max_penalty_limit(penalty_for_submission) + end + + def handle_no_submission(submission_records) + submission_records.any? ? 0 : @max_penalty_for_no_submission + end + + def apply_max_penalty_limit(penalty_for_submission) + if penalty_for_submission > @max_penalty_for_no_submission + @max_penalty_for_no_submission + else + penalty_for_submission + end + end + + def calculate_review_penalty + calculate_penalty(@assignment.num_reviews, @review_deadline_type_id, ReviewResponseMap, :get_reviewer) + end + + def calculate_meta_review_penalty + calculate_penalty(@assignment.num_review_of_reviews, @meta_review_deadline_type_id, MetareviewResponseMap, :id) + end + + private + + def calculate_penalty(num_reviews_required, deadline_type_id, mapping_class, reviewer_method) + return 0 if num_reviews_required <= 0 || @penalty_per_unit.nil? + + review_mappings = mapping_class.where(reviewer_id: @participant.send(reviewer_method).id) + review_due_date = AssignmentDueDate.where(deadline_type_id: deadline_type_id, parent_id: @assignment.id).first + return 0 if review_due_date.nil? + + compute_penalty_on_reviews(review_mappings, review_due_date.due_at, num_reviews_required) + end + + def compute_penalty_on_reviews(review_mappings, review_due_date, num_of_reviews_required, penalty_unit, penalty_per_unit, max_penalty) + review_timestamps = collect_review_timestamps(review_mappings) + review_timestamps.sort! + + penalty = 0 + + num_of_reviews_required.times do |i| + if review_timestamps[i] + penalty += calculate_review_penalty(review_timestamps[i], review_due_date, penalty_unit, penalty_per_unit, max_penalty) + else + penalty = apply_max_penalty_if_missing(max_penalty) + end + end + + penalty + end + + private + + def collect_review_timestamps(review_mappings) + review_mappings.filter_map do |map| + Response.find_by(map_id: map.id)&.created_at unless map.response.empty? + end + end + + def calculate_review_penalty(submission_date, due_date, penalty_unit, penalty_per_unit, max_penalty) + return 0 if submission_date <= due_date + + time_difference = submission_date - due_date + penalty_units = calculate_penalty_units(time_difference, penalty_unit) + [penalty_units * penalty_per_unit, max_penalty].min + end + + def apply_max_penalty_if_missing(max_penalty) + max_penalty + end + + def calculate_penalty_units(time_difference, penalty_unit) + case penalty_unit + when 'Minute' + time_difference / 60 + when 'Hour' + time_difference / 3600 + when 'Day' + time_difference / 86_400 + end + end +end \ No newline at end of file diff --git a/app/helpers/scorable_helper.rb b/app/helpers/scorable_helper.rb index deac24055..cebbbf0ee 100644 --- a/app/helpers/scorable_helper.rb +++ b/app/helpers/scorable_helper.rb @@ -8,10 +8,10 @@ def calculate_total_score # answer for scorable questions, and they will not be counted towards the total score) sum = 0 - question_ids = scores.map(&:question_id) + item_ids = scores.map(&:item_id) - # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids - questions = Item.find_with_order(question_ids) + # We use find with order here to ensure that the list of questions we get is in the same order as that of item_ids + questions = Item.find_with_order(item_ids) scores.each_with_index do |score, idx| item = questions[idx] @@ -35,10 +35,10 @@ def maximum_score # Only count the scorable questions, only when the answer is not nil (we accept nil as # answer for scorable questions, and they will not be counted towards the total score) total_weight = 0 - question_ids = scores.map(&:question_id) + item_ids = scores.map(&:item_id) - # We use find with order here to ensure that the list of questions we get is in the same order as that of question_ids - questions = Item.find_with_order(question_ids) + # We use find with order here to ensure that the list of questions we get is in the same order as that of item_ids + questions = Item.find_with_order(item_ids) scores.each_with_index do |score, idx| total_weight += questions[idx].weight unless score.answer.nil? || !questions[idx].scorable? @@ -60,7 +60,7 @@ def questionnaire_by_answer(answer) assignment = map.response_assignment questionnaire = Questionnaire.find(assignment.review_questionnaire_id) else - questionnaire = Item.find(answer.question_id).questionnaire + questionnaire = Item.find(answer.item_id).questionnaire end questionnaire end diff --git a/app/helpers/security_helper.rb b/app/helpers/security_helper.rb new file mode 100644 index 000000000..4f42faff1 --- /dev/null +++ b/app/helpers/security_helper.rb @@ -0,0 +1,35 @@ +module SecurityHelper + def special_chars + special = '/\\?<>|&$#' + special + end + + def contains_special_chars?(str) + special = special_chars + regex = /[#{special.gsub(/./) { |char| "\\#{char}" }}]/ + + !(str =~ regex).nil? + end + + def warn_for_special_chars(str, field_name) + if contains_special_chars? str + flash[:error] = field_name + " must not contain special characters '" + special_chars + "'." + return true + end + false + end + + def json_valid?(str) + JSON.parse(str) + true + rescue JSON::ParserError, TypeError + false + end + + def date_valid?(date) + Date.parse(date) + true + rescue ArgumentError + false + end +end \ No newline at end of file diff --git a/app/models/Item.rb b/app/models/Item.rb index 57217459d..7d8cf2ed5 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -3,7 +3,7 @@ class Item < ApplicationRecord before_create :set_seq belongs_to :questionnaire # each item belongs to a specific questionnaire - has_many :answers, dependent: :destroy, foreign_key: 'question_id' + has_many :answers, dependent: :destroy, foreign_key: 'item_id' attr_accessor :choice_strategy validates :seq, presence: true, numericality: true # sequence must be numeric @@ -14,6 +14,10 @@ class Item < ApplicationRecord def scorable? false end + + def scored? + question_type.in?(%w[ScaleItem CriterionItem]) + end def set_seq self.seq = questionnaire.items.size + 1 @@ -51,4 +55,22 @@ def render def validate_item strategy.validate(self) end + + def max_score + weight + end + + def self.for(record) + klass = case record.question_type + when 'Criterion' + Criterion + when 'Scale' + Scale + else + Item + end + + # Cast the existing record to the desired subclass + klass.new(record.attributes) + end end \ No newline at end of file diff --git a/app/models/analytic/assignment_team_analytic.rb b/app/models/analytic/assignment_team_analytic.rb new file mode 100644 index 000000000..3d7b59609 --- /dev/null +++ b/app/models/analytic/assignment_team_analytic.rb @@ -0,0 +1,108 @@ +# require 'analytic/response_analytic' +module Analytic::AssignmentTeamAnalytic + #======= general ==========# + def num_participants + participants.count + end + + def num_reviews + responses.count + end + + #========== score ========# + def average_review_score + if num_reviews == 0 + 0 + else + review_scores.inject(:+).to_f / num_reviews + end + end + + def max_review_score + review_scores.max + end + + def min_review_score + review_scores.min + end + + #======= word count =======# + def total_review_word_count + review_word_counts.inject(:+) + end + + def average_review_word_count + if num_reviews == 0 + 0 + else + total_review_word_count.to_f / num_reviews + end + end + + def max_review_word_count + review_word_counts.max + end + + def min_review_word_count + review_word_counts.min + end + + #===== character count ====# + def total_review_character_count + review_character_counts.inject(:+) + end + + def average_review_character_count + if num_reviews == 0 + 0 + else + total_review_character_count.to_f / num_reviews + end + end + + def max_review_character_count + review_character_counts.max + end + + def min_review_character_count + review_character_counts.min + end + + def review_character_counts + list = [] + responses.each do |response| + list << response.total_character_count + end + if list.empty? + [0] + else + list + end + end + + # return an array containing the score of all the reviews + def review_scores + list = [] + responses.each do |response| + list << response.average_score + end + if list.empty? + [0] + else + list + end + end + + def review_word_counts + list = [] + responses.each do |response| + list << response.total_word_count + end + if list.empty? + [0] + else + list + end + end +end + \ No newline at end of file diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 144cd5369..ad2659277 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -2,6 +2,7 @@ class Assignment < ApplicationRecord include MetricHelper + include DueDateActions has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'parent_id', dependent: :destroy has_many :users, through: :participants, inverse_of: :assignment has_many :teams, class_name: 'AssignmentTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :assignment @@ -11,7 +12,7 @@ class Assignment < ApplicationRecord has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy + # Note: due_dates association is provided by DueDateActions mixin belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments @@ -117,17 +118,20 @@ def copy # Save the copied assignment to the database copied_assignment.save + # Copy all due dates to the new assignment + copy_due_dates_to(copied_assignment) + copied_assignment end def is_calibrated? is_calibrated end - + def pair_programming_enabled? enable_pair_programming end - + def has_badge? has_badge end @@ -195,4 +199,16 @@ def varying_rubrics_by_round? # Check if any rubric has a specified round rubric_with_round.present? end + + def review_rounds(questionnaireType) + review_rounds = [] + if varying_rubrics_by_round? + all_questionnaires = AssignmentQuestionnaire.where(assignment_id: id).where.not(used_in_round: nil).all + all_questionnaires.each do |q| + review_rounds << q.used_in_round if q.questionnaire.questionnaire_type == "#{questionnaireType}Questionnaire" + end + end + review_rounds + end + end diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 4edeee5c6..d232413b1 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class AssignmentParticipant < Participant + include ReviewAggregator belongs_to :user validates :handle, presence: true - def set_handle self.handle = if user.handle.nil? || (user.handle == '') user.name @@ -16,4 +16,7 @@ def set_handle self.save end -end + def aggregate_teammate_review_grade(teammate_review_mappings) + compute_average_review_score(teammate_review_mappings) + end +end \ No newline at end of file diff --git a/app/models/assignment_survey_response_map.rb b/app/models/assignment_survey_response_map.rb index 4e4ca5c36..d5c31ef5c 100644 --- a/app/models/assignment_survey_response_map.rb +++ b/app/models/assignment_survey_response_map.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class AssignmentSurveyResponseMap < SurveyResponseMap + include ResponseMapSubclassTitles belongs_to :assignment, foreign_key: 'reviewed_object_id' def survey_parent assignment diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 77a99cfd4..95ad1fe14 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true class AssignmentTeam < Team + include Analytic::AssignmentTeamAnalytic + include ReviewAggregator # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' + has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id' + has_many :review_response_maps, foreign_key: 'reviewee_id' + has_many :responses, through: :review_response_maps, foreign_key: 'map_id' # Copies the current assignment team to a course team @@ -22,6 +27,34 @@ def copy_to_course_team(course) course_team # Returns the newly created course team object end + # Get the review response map + def review_map_type + 'ReviewResponseMap' + end + + def fullname + name + end + + # Use current object (AssignmentTeam) as reviewee and create the ReviewResponseMap record + def assign_reviewer(reviewer) + assignment = Assignment.find(parent_id) + raise 'The assignment cannot be found.' if assignment.nil? + + ReviewResponseMap.create(reviewee_id: id, reviewer_id: reviewer.get_reviewer.id, reviewed_object_id: assignment.id, team_reviewing_enabled: assignment.team_reviewing_enabled) + end + + # Whether the team has submitted work or not + def has_submissions? + submitted_files.any? || submitted_hyperlinks.present? + end + + # Computes the average review grade for an assignment team. + # This method aggregates scores from all ReviewResponseMaps (i.e., all reviewers of the team). + def aggregate_review_grade + compute_average_review_score(review_mappings) + end + protected # Validates if a user is eligible to join the team @@ -33,11 +66,11 @@ def validate_membership(user) private - # Validates that the team is an AssignmentTeam or a subclass (e.g., MentoredTeam) def validate_assignment_team_type unless self.kind_of?(AssignmentTeam) errors.add(:type, 'must be an AssignmentTeam or its subclass') end - end -end + end + +end \ No newline at end of file diff --git a/app/models/concerns/due_date_actions.rb b/app/models/concerns/due_date_actions.rb new file mode 100644 index 000000000..a625a41a3 --- /dev/null +++ b/app/models/concerns/due_date_actions.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +module DueDateActions + extend ActiveSupport::Concern + + included do + has_many :due_dates, as: :parent, dependent: :destroy + include DueDateQueries + end + + # Generic activity permission checker that determines if an activity is permissible + # based on the current deadline state for this parent object + def activity_permissible?(activity) + current_deadline = next_due_date + return false unless current_deadline + + current_deadline.activity_permissible?(activity) + end + + # Syntactic sugar methods for common activities + # These provide clean, readable method names while avoiding DRY violations + def submission_permissible? + activity_permissible?(:submission) + end + + def review_permissible? + activity_permissible?(:review) + end + + def teammate_review_permissible? + activity_permissible?(:teammate_review) + end + + def metareview_permissible? + activity_permissible?(:metareview) + end + + def quiz_permissible? + activity_permissible?(:quiz) + end + + def team_formation_permissible? + activity_permissible?(:team_formation) + end + + def signup_permissible? + activity_permissible?(:signup) + end + + def drop_topic_permissible? + activity_permissible?(:drop_topic) + end + + # Check activity permissions for a specific deadline type + def activity_permissible_for_type?(activity, deadline_type_name) + deadline = find_deadline(deadline_type_name) + return false unless deadline + + deadline.activity_permissible?(activity) + end + + # Get the current stage/deadline for a specific action + def current_stage_for(action) + current_deadline_for(action) + end + + # Check if a specific action is currently allowed based on deadlines + def action_allowed?(action) + case action.to_s.downcase + when 'submit', 'submission' + submission_permissible? + when 'review' + review_permissible? + when 'teammate_review' + teammate_review_permissible? + when 'metareview' + metareview_permissible? + when 'quiz' + quiz_permissible? + when 'team_formation' + team_formation_permissible? + when 'signup' + signup_permissible? + when 'drop_topic' + drop_topic_permissible? + else + false + end + end + + # Get all currently allowed actions + def allowed_actions + actions = [] + actions << 'submission' if submission_permissible? + actions << 'review' if review_permissible? + actions << 'teammate_review' if teammate_review_permissible? + actions << 'metareview' if metareview_permissible? + actions << 'quiz' if quiz_permissible? + actions << 'team_formation' if team_formation_permissible? + actions << 'signup' if signup_permissible? + actions << 'drop_topic' if drop_topic_permissible? + actions + end + + # Check if any actions are currently allowed + def has_allowed_actions? + allowed_actions.any? + end + + # Get permission summary for all actions + def action_permissions_summary + { + submission: submission_permissible?, + review: review_permissible?, + teammate_review: teammate_review_permissible?, + metareview: metareview_permissible?, + quiz: quiz_permissible?, + team_formation: team_formation_permissible?, + signup: signup_permissible?, + drop_topic: drop_topic_permissible?, + has_any_permissions: has_allowed_actions? + } + end + + # Topic-specific permission checking + def activity_permissible_for_topic?(activity, topic_id) + deadline = current_stage_for_topic(topic_id, activity) + return false unless deadline + + deadline.activity_permissible?(activity) + end + + # Topic-specific syntactic sugar methods + def submission_permissible_for_topic?(topic_id) + activity_permissible_for_topic?(:submission, topic_id) + end + + def review_permissible_for_topic?(topic_id) + activity_permissible_for_topic?(:review, topic_id) + end + + def quiz_permissible_for_topic?(topic_id) + activity_permissible_for_topic?(:quiz, topic_id) + end + + # Copy all due dates to a new parent object + def copy_due_dates_to(new_parent) + due_dates.find_each do |due_date| + due_date.copy_to(new_parent) + end + end + + # Duplicate due dates with modifications + def duplicate_due_dates_with_changes(new_parent, changes = {}) + due_dates.map do |due_date| + due_date.duplicate_with_changes(changes.merge(parent: new_parent)) + end + end + + # Create a new due date for this parent + def create_due_date(deadline_type_name, due_at, round: 1, **attributes) + deadline_type = DeadlineType.find_by_name(deadline_type_name) + raise ArgumentError, "Invalid deadline type: #{deadline_type_name}" unless deadline_type + + due_dates.create!( + deadline_type: deadline_type, + due_at: due_at, + round: round, + **attributes + ) + end + + # Update or create a due date for a specific type and round + def set_deadline(deadline_type_name, due_at, round: 1, **attributes) + deadline = find_deadline(deadline_type_name, round) + + if deadline + deadline.update!(due_at: due_at, **attributes) + deadline + else + create_due_date(deadline_type_name, due_at, round: round, **attributes) + end + end + + # Remove due dates of a specific type + def remove_deadlines_of_type(deadline_type_name) + due_dates.joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .destroy_all + end + + # Shift all deadlines by a certain amount of time + def shift_deadlines(time_delta) + due_dates.update_all("due_at = due_at + INTERVAL #{time_delta.to_i} SECOND") + end + + # Shift deadlines of a specific type + def shift_deadlines_of_type(deadline_type_name, time_delta) + due_dates.joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .update_all("due_at = due_at + INTERVAL #{time_delta.to_i} SECOND") + end + + # Check if deadlines are properly ordered (submission before review, etc.) + def deadlines_properly_ordered? + workflow_deadlines = due_dates.joins(:deadline_type) + .where(deadline_types: { name: DeadlineType.workflow_order }) + .order(:due_at) + + previous_position = -1 + workflow_deadlines.each do |deadline| + current_position = deadline.deadline_type.workflow_position + return false if current_position < previous_position + previous_position = current_position + end + + true + end + + # Get deadline ordering violations + def deadline_ordering_violations + violations = [] + workflow_deadlines = due_dates.joins(:deadline_type) + .where(deadline_types: { name: DeadlineType.workflow_order }) + .order(:due_at) + + workflow_deadlines.each_with_index do |deadline, index| + next_deadline = workflow_deadlines[index + 1] + next unless next_deadline + + if deadline.deadline_type.workflow_position > next_deadline.deadline_type.workflow_position + violations << { + earlier_deadline: deadline, + later_deadline: next_deadline, + issue: "#{next_deadline.deadline_type_name} should come after #{deadline.deadline_type_name}" + } + end + end + + violations + end + + # Validate that all required deadline types are present + def has_required_deadlines?(required_types = ['submission']) + required_types.all? { |type| has_deadline_type?(type) } + end + + # Get missing required deadline types + def missing_required_deadlines(required_types = ['submission']) + required_types.reject { |type| has_deadline_type?(type) } + end + + # Check if this object has a complete deadline schedule + def has_complete_deadline_schedule? + has_deadline_type?('submission') && + (has_deadline_type?('review') || has_deadline_type?('quiz')) + end + + # Get the workflow stage based on current time and deadlines + def current_workflow_stage + current_deadline = next_due_date + return 'inactive' unless current_deadline + + if current_deadline.overdue? + previous = current_deadline.previous_deadline + return previous ? previous.deadline_type_name : 'pre-submission' + else + current_deadline.deadline_type_name + end + end + + # Check if object is in a specific workflow stage + def in_stage?(stage_name) + current_workflow_stage == stage_name + end + + # Get all stages this object will go through + def workflow_stages + used_deadline_types.sort_by(&:workflow_position).map(&:name) + end + + # Check if a stage has been completed + def stage_completed?(stage_name) + deadline = find_deadline(stage_name) + return false unless deadline + + deadline.overdue? + end + + # Get completion status for all stages + def stage_completion_status + workflow_stages.map do |stage| + { + stage: stage, + completed: stage_completed?(stage), + deadline: find_deadline(stage) + } + end + end +end diff --git a/app/models/concerns/due_date_permissions.rb b/app/models/concerns/due_date_permissions.rb new file mode 100644 index 000000000..69e39538c --- /dev/null +++ b/app/models/concerns/due_date_permissions.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +module DueDatePermissions + extend ActiveSupport::Concern + + # Permission checking methods that combine deadline-based and role-based logic + # These methods provide a unified interface for checking if actions are allowed + + def can_submit? + return false unless submission_allowed_id + + deadline_right = DeadlineRight.find_by(id: submission_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_review? + return false unless review_allowed_id + + deadline_right = DeadlineRight.find_by(id: review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_take_quiz? + return false unless quiz_allowed_id + + deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_teammate_review? + return false unless teammate_review_allowed_id + + deadline_right = DeadlineRight.find_by(id: teammate_review_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + def can_metareview? + return false unless respond_to?(:metareview_allowed_id) && metareview_allowed_id + + deadline_right = DeadlineRight.find_by(id: metareview_allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Generic permission checker that can be extended for any action + def activity_permissible?(activity) + permission_field = "#{activity}_allowed_id" + return false unless respond_to?(permission_field) + + allowed_id = public_send(permission_field) + return false unless allowed_id + + deadline_right = DeadlineRight.find_by(id: allowed_id) + deadline_right&.name&.in?(%w[OK Late]) + end + + # Syntactic sugar methods for common activities + def submission_permissible? + activity_permissible?(:submission) + end + + def review_permissible? + activity_permissible?(:review) + end + + def teammate_review_permissible? + activity_permissible?(:teammate_review) + end + + def quiz_permissible? + activity_permissible?(:quiz) + end + + def metareview_permissible? + activity_permissible?(:metareview) if respond_to?(:metareview_allowed_id) + end + + # Check if deadline allows late submissions + def allows_late_submission? + return false unless submission_allowed_id + + deadline_right = DeadlineRight.find_by(id: submission_allowed_id) + deadline_right&.name == 'Late' + end + + def allows_late_review? + return false unless review_allowed_id + + deadline_right = DeadlineRight.find_by(id: review_allowed_id) + deadline_right&.name == 'Late' + end + + def allows_late_quiz? + return false unless quiz_allowed_id + + deadline_right = DeadlineRight.find_by(id: quiz_allowed_id) + deadline_right&.name == 'Late' + end + + # Check if any activity is currently allowed + def has_any_permission? + can_submit? || can_review? || can_take_quiz? || can_teammate_review? || + (respond_to?(:can_metareview?) && can_metareview?) + end + + # Get list of currently allowed activities + def allowed_activities + activities = [] + activities << 'submission' if can_submit? + activities << 'review' if can_review? + activities << 'quiz' if can_take_quiz? + activities << 'teammate_review' if can_teammate_review? + activities << 'metareview' if respond_to?(:can_metareview?) && can_metareview? + activities + end + + # Check if this deadline is currently active (allows some action) + def active? + has_any_permission? + end + + # Check if this deadline is completely closed (no actions allowed) + def closed? + !active? + end + + # Check permissions for deadline type compatibility + def deadline_type_permits_action?(action) + return false unless deadline_type + + case action.to_s.downcase + when 'submit', 'submission' + deadline_type.allows_submission? + when 'review' + deadline_type.allows_review? + when 'quiz' + deadline_type.allows_quiz? + when 'teammate_review' + deadline_type.allows_review? + when 'metareview' + deadline_type.allows_review? + else + false + end + end + + # Comprehensive permission check combining deadline type and deadline rights + def permits_action?(action) + deadline_type_permits_action?(action) && activity_permissible?(action) + end + + # Get permission status for an action (OK, Late, No) + def permission_status_for(action) + permission_field = "#{action}_allowed_id" + return 'No' unless respond_to?(permission_field) + + allowed_id = public_send(permission_field) + return 'No' unless allowed_id + + deadline_right = DeadlineRight.find_by(id: allowed_id) + deadline_right&.name || 'No' + end + + # Check if deadline is in grace period (allows late submissions) + def in_grace_period_for?(action) + permission_status_for(action) == 'Late' + end + + # Check if deadline is fully open for action + def fully_open_for?(action) + permission_status_for(action) == 'OK' + end + + # Get human-readable permission description + def permission_description_for(action) + status = permission_status_for(action) + case status + when 'OK' + "#{action.to_s.humanize} is allowed" + when 'Late' + "#{action.to_s.humanize} is allowed with late penalty" + when 'No' + "#{action.to_s.humanize} is not allowed" + else + "#{action.to_s.humanize} status unknown" + end + end + + # Get a summary of all permissions for this deadline + def permissions_summary + { + submission: permission_status_for(:submission), + review: permission_status_for(:review), + quiz: permission_status_for(:quiz), + teammate_review: permission_status_for(:teammate_review), + active: active?, + closed: closed? + } + end +end diff --git a/app/models/concerns/due_date_queries.rb b/app/models/concerns/due_date_queries.rb new file mode 100644 index 000000000..25901ab3c --- /dev/null +++ b/app/models/concerns/due_date_queries.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +module DueDateQueries + extend ActiveSupport::Concern + + included do + # Scopes for common deadline queries + scope :upcoming, -> { where('due_at > ?', Time.current).order(:due_at) } + scope :overdue, -> { where('due_at < ?', Time.current).order(:due_at) } + scope :today, -> { where(due_at: Time.current.beginning_of_day..Time.current.end_of_day) } + scope :this_week, -> { where(due_at: Time.current.beginning_of_week..Time.current.end_of_week) } + scope :for_deadline_type, ->(type_name) { joins(:deadline_type).where(deadline_types: { name: type_name }) } + scope :for_round, ->(round_num) { where(round: round_num) } + scope :active_deadlines, -> { where('due_at > ?', Time.current) } + scope :by_deadline_type, -> { joins(:deadline_type).order('deadline_types.name') } + end + + class_methods do + # Find next upcoming deadline for any parent + def next_deadline + upcoming.first + end + + # Find deadlines by type name + def of_type(deadline_type_name) + joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) + end + + # Get deadline statistics + def deadline_stats + { + total: count, + upcoming: upcoming.count, + overdue: overdue.count, + today: today.count, + this_week: this_week.count + } + end + + # Find deadlines within a date range + def between_dates(start_date, end_date) + where(due_at: start_date..end_date) + end + + # Find deadlines for specific actions + def for_submission + of_type('submission') + end + + def for_review + of_type('review') + end + + def for_quiz + of_type('quiz') + end + + def for_teammate_review + of_type('teammate_review') + end + + def for_metareview + of_type('metareview') + end + + # Find deadlines that allow specific actions + def allowing_submission + where(submission_allowed_id: [2, 3]) # Late and OK + end + + def allowing_review + where(review_allowed_id: [2, 3]) # Late and OK + end + + def allowing_quiz + where(quiz_allowed_id: [2, 3]) # Late and OK + end + + # Get deadlines grouped by type + def grouped_by_type + joins(:deadline_type) + .group('deadline_types.name') + .order('deadline_types.name') + end + end + + # Instance methods for parent objects (Assignment, SignUpTopic) + # These methods should be included in Assignment and SignUpTopic models + + # Get next due date for this parent + def next_due_date + due_dates.upcoming.first + end + + # Get the most recently passed deadline + def last_due_date + due_dates.overdue.order(due_at: :desc).first + end + + # Find current stage/deadline for a specific action + def current_deadline_for(action) + deadline_type_name = map_action_to_deadline_type(action) + return nil unless deadline_type_name + + # First try to find an active deadline for this action + current = due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + + # If no future deadline, get the most recent past deadline + current ||= due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .order(due_at: :desc) + .first + + current + end + + # Get upcoming deadlines with limit + def upcoming_deadlines(limit: 5) + due_dates.upcoming.limit(limit) + end + + # Get overdue deadlines + def overdue_deadlines + due_dates.overdue + end + + # Check if there are any future deadlines + def has_future_deadlines? + due_dates.upcoming.exists? + end + + # Get deadlines for a specific round + def deadlines_for_round(round_number) + due_dates.where(round: round_number).order(:due_at) + end + + # Find deadline by type and round + def find_deadline(deadline_type_name, round_number = nil) + query = due_dates.joins(:deadline_type).where(deadline_types: { name: deadline_type_name }) + query = query.where(round: round_number) if round_number + query.order(:due_at).first + end + + # Get all deadline types used by this object + def used_deadline_types + due_dates + .joins(:deadline_type) + .select('DISTINCT deadline_types.*') + .map(&:deadline_type) + end + + # Check if this object has a specific type of deadline + def has_deadline_type?(deadline_type_name) + due_dates + .joins(:deadline_type) + .where(deadline_types: { name: deadline_type_name }) + .exists? + end + + # Get deadlines that are currently active (allowing some action) + def active_deadlines + due_dates.select(&:active?) + end + + # Get deadline summary for display + def deadline_summary + { + total_deadlines: due_dates.count, + upcoming_count: upcoming_deadlines.count, + overdue_count: overdue_deadlines.count, + deadline_types: used_deadline_types.map(&:name), + next_deadline: next_due_date, + has_active_deadlines: active_deadlines.any? + } + end + + # Find the current stage for topic-specific deadlines + def current_stage_for_topic(topic_id, action) + deadline_type_name = map_action_to_deadline_type(action) + return nil unless deadline_type_name + + # Try topic-specific deadline first + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type_name }) + .where('due_at >= ?', Time.current) + .order(:due_at) + .first + + # Fall back to assignment-level deadline + topic_deadline || current_deadline_for(action) + end + + # Get all deadlines affecting a specific topic + def deadlines_for_topic(topic_id) + assignment_deadlines = due_dates.where(parent_type: 'Assignment') + topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic') + + (assignment_deadlines + topic_deadlines).sort_by(&:due_at) + end + + # Check if assignment has topic-specific overrides + def has_topic_deadline_overrides? + due_dates.where(parent_type: 'SignUpTopic').exists? + end + + # Get deadline comparison between assignment and topic + def deadline_comparison_for_topic(topic_id) + assignment_deadlines = due_dates.where(parent_type: 'Assignment').includes(:deadline_type) + topic_deadlines = due_dates.where(parent_id: topic_id, parent_type: 'SignUpTopic').includes(:deadline_type) + + { + assignment_deadlines: assignment_deadlines, + topic_deadlines: topic_deadlines, + has_overrides: topic_deadlines.any? + } + end + + # Find conflicts between assignment and topic deadlines + def deadline_conflicts_for_topic(topic_id) + conflicts = [] + + used_deadline_types.each do |deadline_type| + assignment_deadline = find_deadline(deadline_type.name) + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type.name }) + .first + + if assignment_deadline && topic_deadline + if assignment_deadline.due_at != topic_deadline.due_at + conflicts << { + deadline_type: deadline_type.name, + assignment_due: assignment_deadline.due_at, + topic_due: topic_deadline.due_at, + difference: topic_deadline.due_at - assignment_deadline.due_at + } + end + end + end + + conflicts + end + + # Get the effective deadline for a topic (topic-specific or assignment fallback) + def effective_deadline_for_topic(topic_id, deadline_type_name) + # First check for topic-specific deadline + topic_deadline = due_dates + .joins(:deadline_type) + .where(parent_id: topic_id, parent_type: 'SignUpTopic') + .where(deadline_types: { name: deadline_type_name }) + .first + + # Fall back to assignment deadline + topic_deadline || find_deadline(deadline_type_name) + end + + private + + # Map action names to deadline type names + def map_action_to_deadline_type(action) + case action.to_s.downcase + when 'submit', 'submission' + 'submission' + when 'review', 'peer_review' + 'review' + when 'teammate_review' + 'teammate_review' + when 'metareview', 'meta_review' + 'metareview' + when 'quiz' + 'quiz' + when 'team_formation' + 'team_formation' + when 'signup' + 'signup' + when 'drop_topic' + 'drop_topic' + else + nil + end + end +end diff --git a/app/models/concerns/review_aggregator.rb b/app/models/concerns/review_aggregator.rb new file mode 100644 index 000000000..95e25e65e --- /dev/null +++ b/app/models/concerns/review_aggregator.rb @@ -0,0 +1,22 @@ +module ReviewAggregator + extend ActiveSupport::Concern + + # Generic method to compute average review grade from a collection of response maps + def compute_average_review_score(maps) + return nil if maps.blank? + + total_score = 0.0 + total_reviewers = 0 + + maps.each do |map| + score = map.aggregate_reviewers_score + next if score.nil? + + total_score += score + total_reviewers += 1 + end + + return nil if total_reviewers.zero? + ((total_score / total_reviewers) * 100).round(2) + end +end \ No newline at end of file diff --git a/app/models/course_survey_response_map.rb b/app/models/course_survey_response_map.rb index 79c5a5462..ac966df46 100644 --- a/app/models/course_survey_response_map.rb +++ b/app/models/course_survey_response_map.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class CourseSurveyResponseMap < SurveyResponseMap + include ResponseMapSubclassTitles belongs_to :course, foreign_key: 'reviewed_object_id' def questionnaire diff --git a/app/models/course_team.rb b/app/models/course_team.rb index 91ae746ac..1230f0175 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -43,4 +43,4 @@ def type_must_be_course_team errors.add(:type, 'must be CourseTeam') end end -end +end diff --git a/app/models/criterion.rb b/app/models/criterion.rb index 6ee5fc571..4dfcaae2d 100644 --- a/app/models/criterion.rb +++ b/app/models/criterion.rb @@ -2,6 +2,10 @@ class Criterion < ScoredItem validates :size, presence: true + + def max_score + questionnaire.max_question_score * weight + end def edit { diff --git a/app/models/deadline_right.rb b/app/models/deadline_right.rb new file mode 100644 index 000000000..66121a832 --- /dev/null +++ b/app/models/deadline_right.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +class DeadlineRight < ApplicationRecord + # Constants for deadline right IDs + NO = 1 + LATE = 2 + OK = 3 + + validates :name, presence: true, uniqueness: true + validates :description, presence: true + + # Scopes for different permission levels + scope :allowing, -> { where(name: %w[OK Late]) } + scope :denying, -> { where(name: 'No') } + scope :with_penalty, -> { where(name: 'Late') } + scope :without_penalty, -> { where(name: 'OK') } + + # Class methods for finding deadline rights + def self.find_by_name(name) + find_by(name: name.to_s) + end + + def self.no + find_by_name('No') + end + + def self.late + find_by_name('Late') + end + + def self.ok + find_by_name('OK') + end + + # Permission checking methods + def allows_action? + %w[OK Late].include?(name) + end + + def denies_action? + name == 'No' + end + + def allows_with_penalty? + name == 'Late' + end + + def allows_without_penalty? + name == 'OK' + end + + # Semantic helper methods + def no? + name == 'No' + end + + def late? + name == 'Late' + end + + def ok? + name == 'OK' + end + + # Display methods + def display_name + name + end + + def display_description + description + end + + def to_s + name + end + + def css_class + case name + when 'OK' + 'deadline-allowed' + when 'Late' + 'deadline-late' + when 'No' + 'deadline-denied' + else + 'deadline-unknown' + end + end + + def icon + case name + when 'OK' + 'check-circle' + when 'Late' + 'clock' + when 'No' + 'x-circle' + else + 'question-circle' + end + end + + # Method to get human-readable status with context + def status_with_context(action) + case name + when 'OK' + "#{action.to_s.humanize} is allowed" + when 'Late' + "#{action.to_s.humanize} is allowed with late penalty" + when 'No' + "#{action.to_s.humanize} is not allowed" + else + "#{action.to_s.humanize} status unknown" + end + end + + # Comparison methods + def more_permissive_than?(other) + return false unless other.is_a?(DeadlineRight) + + permission_level > other.permission_level + end + + def less_permissive_than?(other) + return false unless other.is_a?(DeadlineRight) + + permission_level < other.permission_level + end + + def permission_level + case name + when 'No' + 0 + when 'Late' + 1 + when 'OK' + 2 + else + -1 + end + end + + def <=>(other) + return nil unless other.is_a?(DeadlineRight) + + permission_level <=> other.permission_level + end + + # Method to seed the deadline rights (for use in migrations/seeds) + def self.seed_deadline_rights! + deadline_rights = [ + { id: NO, name: 'No', description: 'Action is not allowed' }, + { id: LATE, name: 'Late', description: 'Action is allowed with late penalty' }, + { id: OK, name: 'OK', description: 'Action is allowed without penalty' } + ] + + deadline_rights.each do |right_attrs| + find_or_create_by(id: right_attrs[:id]) do |dr| + dr.name = right_attrs[:name] + dr.description = right_attrs[:description] + end + end + end + + # Validation methods + def self.valid_right_names + %w[No Late OK] + end + + def self.validate_right_name(name) + valid_right_names.include?(name.to_s) + end + + # Statistics methods + def usage_count + # Count how many due_dates reference this deadline right + # This is a general count across all permission fields + count = 0 + + # Check submission permissions + count += DueDate.where(submission_allowed_id: id).count + + # Check review permissions + count += DueDate.where(review_allowed_id: id).count + + # Check quiz permissions + count += DueDate.where(quiz_allowed_id: id).count + + # Check teammate review permissions + count += DueDate.where(teammate_review_allowed_id: id).count + + # Check other permission fields if they exist + if DueDate.column_names.include?('resubmission_allowed_id') + count += DueDate.where(resubmission_allowed_id: id).count + end + + if DueDate.column_names.include?('rereview_allowed_id') + count += DueDate.where(rereview_allowed_id: id).count + end + + if DueDate.column_names.include?('review_of_review_allowed_id') + count += DueDate.where(review_of_review_allowed_id: id).count + end + + count + end + + # Check if this deadline right is being used + def in_use? + usage_count > 0 + end + + private + + # Prevent deletion if deadline right is in use + def cannot_delete_if_in_use + return unless in_use? + + errors.add(:base, 'Cannot delete deadline right that is being used by due dates') + throw :abort + end + + before_destroy :cannot_delete_if_in_use +end diff --git a/app/models/deadline_type.rb b/app/models/deadline_type.rb new file mode 100644 index 000000000..bf490f4a4 --- /dev/null +++ b/app/models/deadline_type.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# DeadlineType serves as the canonical source of truth for all deadline categories. +# It replaces hard-coded deadline_type_id comparisons with semantic helper methods. +class DeadlineType < ApplicationRecord + # Constants for deadline type IDs (for backward compatibility) + SUBMISSION = 1 + REVIEW = 2 + TEAMMATE_REVIEW = 3 + METAREVIEW = 5 + DROP_TOPIC = 6 + SIGNUP = 7 + TEAM_FORMATION = 8 + QUIZ = 11 + + validates :name, presence: true, uniqueness: true + validates :description, presence: true + + has_many :due_dates, foreign_key: :deadline_type_id, dependent: :restrict_with_exception + + # Scopes for categorizing deadline types + scope :submission_types, -> { where(name: ['submission']) } + scope :review_types, -> { where(name: ['review', 'metareview', 'teammate_review']) } + scope :quiz_types, -> { where(name: ['quiz']) } + scope :administrative_types, -> { where(name: ['drop_topic', 'signup', 'team_formation']) } + + # Class methods for finding deadline types + def self.find_by_name(name) + find_by(name: name.to_s) + end + + def self.submission + find_by_name('submission') + end + + def self.review + find_by_name('review') + end + + def self.teammate_review + find_by_name('teammate_review') + end + + def self.metareview + find_by_name('metareview') + end + + def self.drop_topic + find_by_name('drop_topic') + end + + def self.signup + find_by_name('signup') + end + + def self.team_formation + find_by_name('team_formation') + end + + def self.quiz + find_by_name('quiz') + end + + # Dynamic method to find deadline type for action + def self.for_action(action_name) + case action_name.to_s.downcase + when 'submit', 'submission' then submission + when 'review' then review + when 'teammate_review' then teammate_review + when 'metareview' then metareview + when 'quiz' then quiz + when 'team_formation' then team_formation + when 'signup' then signup + when 'drop_topic' then drop_topic + else nil + end + end + + # Semantic helper methods for deadline type identification + def submission? + name == 'submission' + end + + def review? + %w[review metareview teammate_review].include?(name) + end + + def teammate_review? + name == 'teammate_review' + end + + def metareview? + name == 'metareview' + end + + def quiz? + name == 'quiz' + end + + def administrative? + %w[drop_topic signup team_formation].include?(name) + end + + def team_formation? + name == 'team_formation' + end + + def signup? + name == 'signup' + end + + def drop_topic? + name == 'drop_topic' + end + + # Permission checking helper methods + def allows_submission? + submission? + end + + def allows_review? + review? + end + + def allows_quiz? + quiz? + end + + def allows_team_formation? + team_formation? + end + + def allows_signup? + signup? + end + + def allows_topic_drop? + drop_topic? + end + + # Category checking methods + def workflow_deadline? + %w[submission review teammate_review metareview].include?(name) + end + + def assessment_deadline? + %w[review metareview teammate_review quiz].include?(name) + end + + def student_action_deadline? + %w[submission quiz signup team_formation drop_topic].include?(name) + end + + # Display methods + def display_name + name.humanize + end + + def to_s + display_name + end + + # Method to seed the deadline types (for use in migrations/seeds) + def self.seed_deadline_types! + deadline_types = [ + { id: SUBMISSION, name: 'submission', description: 'Student work submission deadlines' }, + { id: REVIEW, name: 'review', description: 'Peer review deadlines' }, + { id: TEAMMATE_REVIEW, name: 'teammate_review', description: 'Team member evaluation deadlines' }, + { id: METAREVIEW, name: 'metareview', description: 'Meta-review deadlines' }, + { id: DROP_TOPIC, name: 'drop_topic', description: 'Topic drop deadlines' }, + { id: SIGNUP, name: 'signup', description: 'Course/assignment signup deadlines' }, + { id: TEAM_FORMATION, name: 'team_formation', description: 'Team formation deadlines' }, + { id: QUIZ, name: 'quiz', description: 'Quiz completion deadlines' } + ] + + deadline_types.each do |type_attrs| + find_or_create_by(id: type_attrs[:id]) do |dt| + dt.name = type_attrs[:name] + dt.description = type_attrs[:description] + end + end + end + + # Method to clean up duplicate entries + def self.cleanup_duplicates! + # Remove any duplicate team_formation entries (keep canonical ID 8) + where(name: 'team_formation').where.not(id: TEAM_FORMATION).destroy_all + + # Update any due_dates that reference deleted duplicates + ActiveRecord::Base.connection.execute(<<~SQL) + UPDATE due_dates + SET deadline_type_id = #{TEAM_FORMATION} + WHERE deadline_type_id NOT IN (#{all.pluck(:id).join(',')}) + AND deadline_type_id IS NOT NULL + SQL + end + + # Validation methods + def self.valid_deadline_names + %w[submission review teammate_review metareview drop_topic signup team_formation quiz] + end + + def self.validate_deadline_name(name) + valid_deadline_names.include?(name.to_s) + end + + # Query helpers for associations + def self.used_in_assignments + joins(:due_dates) + .where(due_dates: { parent_type: 'Assignment' }) + .distinct + end + + def self.used_in_topics + joins(:due_dates) + .where(due_dates: { parent_type: 'SignUpTopic' }) + .distinct + end + + # Statistics methods + def due_dates_count + due_dates.count + end + + def active_due_dates_count + due_dates.where('due_at > ?', Time.current).count + end + + def overdue_count + due_dates.where('due_at < ?', Time.current).count + end + + # Comparison and ordering + def <=>(other) + return nil unless other.is_a?(DeadlineType) + + id <=> other.id + end + + # Class method to get deadline type hierarchy for workflow + def self.workflow_order + %w[signup team_formation submission review teammate_review metareview quiz drop_topic] + end + + def workflow_position + self.class.workflow_order.index(name) || Float::INFINITY + end + + # Method to check if this deadline type typically comes before another + def comes_before?(other_type) + return false unless other_type.is_a?(DeadlineType) + + workflow_position < other_type.workflow_position + end + + # Method to get the next logical deadline type in workflow + def next_in_workflow + current_pos = workflow_position + return nil if current_pos == Float::INFINITY + + next_name = self.class.workflow_order[current_pos + 1] + return nil unless next_name + + self.class.find_by_name(next_name) + end + + # Method to get the previous logical deadline type in workflow + def previous_in_workflow + current_pos = workflow_position + return nil if current_pos <= 0 + + prev_name = self.class.workflow_order[current_pos - 1] + return nil unless prev_name + + self.class.find_by_name(prev_name) + end + + # Method for dynamic permission checking based on action + def allows_action?(action) + case action.to_s.downcase + when 'submit', 'submission' then allows_submission? + when 'review' then allows_review? + when 'quiz' then allows_quiz? + when 'team_formation' then allows_team_formation? + when 'signup' then allows_signup? + when 'drop_topic' then allows_topic_drop? + else false + end + end + + private + + # Ensure we maintain referential integrity + def cannot_delete_if_has_due_dates + return unless due_dates.exists? + + errors.add(:base, 'Cannot delete deadline type that has associated due dates') + throw :abort + end + + before_destroy :cannot_delete_if_has_due_dates +end diff --git a/app/models/due_date.rb b/app/models/due_date.rb index ed310bef5..4637dac53 100644 --- a/app/models/due_date.rb +++ b/app/models/due_date.rb @@ -2,59 +2,328 @@ class DueDate < ApplicationRecord include Comparable - # Named constants for teammate review statuses - ALLOWED = 3 - LATE_ALLOWED = 2 - NOT_ALLOWED = 1 + include DueDatePermissions belongs_to :parent, polymorphic: true - validate :due_at_is_valid_datetime + belongs_to :deadline_type, foreign_key: :deadline_type_id + validates :due_at, presence: true + validates :deadline_type_id, presence: true + validates :round, presence: true, numericality: { greater_than: 0 } + validate :due_at_is_valid_datetime - attr_accessor :teammate_review_allowed, :submission_allowed, :review_allowed + # Scopes for common queries + scope :upcoming, -> { where('due_at > ?', Time.current).order(:due_at) } + scope :overdue, -> { where('due_at < ?', Time.current).order(:due_at) } + scope :for_round, ->(round_num) { where(round: round_num) } + scope :for_deadline_type, ->(type_name) { joins(:deadline_type).where(deadline_types: { name: type_name }) } + scope :active, -> { where('due_at > ?', Time.current) } - def due_at_is_valid_datetime - errors.add(:due_at, 'must be a valid datetime') unless due_at.is_a?(Time) + # Instance methods for individual due date operations + + # Create a copy of this due date for a new parent + def copy_to(new_parent) + new_due_date = dup + new_due_date.parent = new_parent + new_due_date.save! + new_due_date + end + + # Duplicate this due date with different attributes + def duplicate_with_changes(changes = {}) + new_due_date = dup + changes.each { |attr, value| new_due_date.public_send("#{attr}=", value) } + new_due_date.save! + new_due_date + end + + # Check if this deadline has passed + def overdue? + due_at < Time.current + end + + # Check if this deadline is upcoming + def upcoming? + due_at > Time.current + end + + # Check if this deadline is today + def due_today? + due_at.to_date == Time.current.to_date + end + + # Check if this deadline is this week + def due_this_week? + due_at >= Time.current.beginning_of_week && due_at <= Time.current.end_of_week + end + + # Time remaining until deadline (returns nil if overdue) + def time_remaining + return nil if overdue? + + due_at - Time.current + end + + # Time since deadline passed (returns nil if not overdue) + def time_overdue + return nil unless overdue? + + Time.current - due_at + end + + # Get human-readable time description + def time_description + if due_today? + "Due today at #{due_at.strftime('%I:%M %p')}" + elsif overdue? + days_overdue = (Time.current.to_date - due_at.to_date).to_i + "#{days_overdue} day#{'s' if days_overdue != 1} overdue" + elsif upcoming? + days_until = (due_at.to_date - Time.current.to_date).to_i + if days_until == 0 + "Due today" + elsif days_until == 1 + "Due tomorrow" + else + "Due in #{days_until} days" + end + else + "Due #{due_at.strftime('%B %d, %Y')}" + end + end + + # Check if this deadline is for a specific type of activity + def for_submission? + deadline_type&.submission? + end + + def for_review? + deadline_type&.review? + end + + def for_quiz? + deadline_type&.quiz? + end + + def for_teammate_review? + deadline_type&.teammate_review? + end + + def for_metareview? + deadline_type&.metareview? + end + + def for_team_formation? + deadline_type&.team_formation? + end + + def for_signup? + deadline_type&.signup? + end + + def for_topic_drop? + deadline_type&.drop_topic? + end + + # Get the deadline type name + def deadline_type_name + deadline_type&.name + end + + # Get human-readable deadline type + def deadline_type_display + deadline_type&.display_name || 'Unknown' + end + + # Check if this deadline allows late submissions + def allows_late_work? + allows_late_submission? || allows_late_review? || allows_late_quiz? + end + + # Get status description + def status_description + if overdue? + allows_late_work? ? 'Overdue (Late work accepted)' : 'Closed' + elsif due_today? + 'Due today' + elsif upcoming? + time_description + else + 'Unknown status' + end + end + + # Check if this deadline is currently in effect + def currently_active? + active? && (upcoming? || (overdue? && allows_late_work?)) + end + + # Get the next deadline after this one (for the same parent) + def next_deadline + parent.due_dates + .where('due_at > ? OR (due_at = ? AND id > ?)', due_at, due_at, id) + .order(:due_at, :id) + .first + end + + # Get the previous deadline before this one (for the same parent) + def previous_deadline + parent.due_dates + .where('due_at < ? OR (due_at = ? AND id < ?)', due_at, due_at, id) + .order(due_at: :desc, id: :desc) + .first end - # Method to compare due dates + # Check if this is the last deadline for the parent + def last_deadline? + next_deadline.nil? + end + + # Check if this is the first deadline for the parent + def first_deadline? + previous_deadline.nil? + end + + # Comparison method for sorting def <=>(other) - due_at <=> other.due_at + return nil unless other.is_a?(DueDate) + + # Primary sort: due_at + comparison = due_at <=> other.due_at + return comparison unless comparison.zero? + + # Secondary sort: deadline type workflow order + if deadline_type && other.deadline_type + workflow_comparison = deadline_type.workflow_position <=> other.deadline_type.workflow_position + return workflow_comparison unless workflow_comparison.zero? + end + + # Tertiary sort: id for consistency + id <=> other.id end - # Return the set of due dates sorted by due_at - def self.sort_due_dates(due_dates) - due_dates.sort_by(&:due_at) + # Get all due dates for the same round and parent + def round_siblings + parent.due_dates.where(round: round).where.not(id: id).order(:due_at) end - # Fetches all due dates for the parent Assignment or Topic - def self.fetch_due_dates(parent_id) - due_dates = where('parent_id = ?', parent_id) - sort_due_dates(due_dates) + # Check if this deadline conflicts with others in the same round + def has_round_conflicts? + round_siblings.where(deadline_type_id: deadline_type_id).exists? end - # Class method to check if any due date is in the future - def self.any_future_due_dates?(due_dates) - due_dates.any? { |due_date| due_date.due_at > Time.zone.now } + # Get summary information about this deadline + def summary + { + id: id, + deadline_type: deadline_type_name, + due_at: due_at, + round: round, + overdue: overdue?, + upcoming: upcoming?, + currently_active: currently_active?, + time_description: time_description, + status: status_description, + permissions: permissions_summary + } end - def set(deadline, assignment_id, max_round) - self.deadline_type_id = deadline - self.parent_id = assignment_id - self.round = max_round - save + # String representation + def to_s + "#{deadline_type_display} - #{time_description}" end - # Fetches due dates from parent then selects the next upcoming due date - def self.next_due_date(parent_id) - due_dates = fetch_due_dates(parent_id) - due_dates.find { |due_date| due_date.due_at > Time.zone.now } + # Detailed string representation + def inspect_details + "DueDate(id: #{id}, type: #{deadline_type_name}, due: #{due_at}, " \ + "round: #{round}, parent: #{parent_type}##{parent_id})" end - # Creates duplicate due dates and assigns them to a new assignment - def copy(new_assignment_id) - new_due_date = dup - new_due_date.parent_id = new_assignment_id - new_due_date.save + # Class methods for collection operations + class << self + # Sort a collection of due dates + def sort_by_due_date(due_dates) + due_dates.sort + end + + # Find the next upcoming due date from a collection + def next_from_collection(due_dates) + due_dates.select(&:upcoming?).min + end + + # Check if any due dates in collection allow late work + def any_allow_late_work?(due_dates) + due_dates.any?(&:allows_late_work?) + end + + # Get due dates grouped by deadline type + def group_by_type(due_dates) + due_dates.group_by(&:deadline_type_name) + end + + # Get due dates grouped by round + def group_by_round(due_dates) + due_dates.group_by(&:round) + end + + # Filter due dates that are currently actionable + def currently_actionable(due_dates) + due_dates.select(&:currently_active?) + end + + # Get statistics for a collection of due dates + def collection_stats(due_dates) + { + total: due_dates.count, + upcoming: due_dates.count(&:upcoming?), + overdue: due_dates.count(&:overdue?), + due_today: due_dates.count(&:due_today?), + active: due_dates.count(&:currently_active?), + types: due_dates.map(&:deadline_type_name).uniq.compact.sort + } + end + + # Find deadline conflicts in a collection + def find_conflicts(due_dates) + conflicts = [] + + due_dates.group_by(&:round).each do |round, round_deadlines| + round_deadlines.group_by(&:deadline_type_name).each do |type, type_deadlines| + if type_deadlines.count > 1 + conflicts << { + round: round, + deadline_type: type, + conflicting_deadlines: type_deadlines.map(&:id) + } + end + end + end + + conflicts + end + + # Get upcoming deadlines across all due dates + def upcoming_across_all(limit: 10) + upcoming.limit(limit).includes(:deadline_type, :parent) + end + + # Get overdue deadlines across all due dates + def overdue_across_all(limit: 10) + overdue.limit(limit).includes(:deadline_type, :parent) + end + end + + private + + def due_at_is_valid_datetime + return unless due_at.present? + + unless due_at.is_a?(Time) || due_at.is_a?(DateTime) || due_at.is_a?(Date) + errors.add(:due_at, 'must be a valid datetime') + end + + if due_at.is_a?(Date) + errors.add(:due_at, 'should include time information, not just date') + end end end diff --git a/app/models/feedback_response_map.rb b/app/models/feedback_response_map.rb index 3839ed4d9..81f98fff1 100644 --- a/app/models/feedback_response_map.rb +++ b/app/models/feedback_response_map.rb @@ -1,19 +1,23 @@ # frozen_string_literal: true +class FeedbackResponseMap < ResponseMap + include ResponseMapSubclassTitles + belongs_to :review, class_name: 'Response', foreign_key: 'reviewed_object_id' + belongs_to :review, class_name: 'Response', foreign_key: 'reviewed_object_id' + belongs_to :reviewer, class_name: 'AssignmentParticipant', dependent: :destroy + def assignment + review.map.assignment + end -class FeedbackResponseMap < ResponseMap - belongs_to :review, class_name: 'Response', foreign_key: 'reviewed_object_id' + def questionnaire + Questionnaire.find_by(id: reviewed_object_id) + end - def assignment - review.map.assignment - end - - def questionnaire - Questionnaire.find_by(id: reviewed_object_id) - end - - def get_title - FEEDBACK_RESPONSE_MAP_TITLE - end -end + def get_title + FEEDBACK_RESPONSE_MAP_TITLE + end + def questionnaire_type + 'AuthorFeedback' + end +end \ No newline at end of file diff --git a/app/models/global_survey_response_map.rb b/app/models/global_survey_response_map.rb index e36c9db1a..4633f6c9b 100644 --- a/app/models/global_survey_response_map.rb +++ b/app/models/global_survey_response_map.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class GlobalSurveyResponseMap < SurveyResponseMap + include ResponseMapSubclassTitles belongs_to :questionnaire, foreign_key: 'reviewed_object_id' def questionnaire Questionnaire.find_by(id: reviewed_object_id) diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index a6771a6a5..77cf25627 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -1,17 +1,10 @@ # frozen_string_literal: true class MentoredTeam < AssignmentTeam - belongs_to :mentor, class_name: 'User' - - #Validates the presence of a mentor in the team - validates :mentor, presence: true # Custom validation to ensure the team type is 'MentoredTeam' validate :type_must_be_mentored_team - # Validates the role of the mentor - validate :mentor_must_have_mentor_role - # adds members to the team who are not mentors def add_member(user) return false if user == mentor @@ -38,10 +31,4 @@ def remove_mentor def type_must_be_mentored_team errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' end - - # Check if the user has been given the role 'mentor' - def mentor_must_have_mentor_role - return unless mentor - errors.add(:mentor, 'must have mentor role') unless mentor.role&.name&.downcase&.include?('mentor') - end end diff --git a/app/models/participant.rb b/app/models/participant.rb index 94872b3db..c48b8f3bd 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -15,7 +15,7 @@ class Participant < ApplicationRecord # Methods def fullname - user.fullname + user.full_name end end diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index b18d066cb..82c950ca2 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -8,33 +8,62 @@ class Questionnaire < ApplicationRecord validate :validate_questionnaire validates :name, presence: true validates :max_question_score, :min_question_score, numericality: true + + + # after_initialize :post_initialization + # @print_name = 'Review Rubric' + + # class << self + # attr_reader :print_name + # end + + # def post_initialization + # self.display_type = 'Review' + # end + + def symbol + 'review'.to_sym + end + + def get_assessments_for(participant) + participant.reviews + end + + # validate the entries for this questionnaire + def validate_questionnaire + errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1 + errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0 + errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score + results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) + errors.add(:name, 'Questionnaire names must be unique.') if results.present? + end # clones the contents of a questionnaire, including the questions and associated advice def self.copy_questionnaire_details(params) orig_questionnaire = Questionnaire.find(params[:id]) questions = Item.where(questionnaire_id: params[:id]) questionnaire = orig_questionnaire.dup + questionnaire.instructor_id = params[:instructor_id] questionnaire.name = 'Copy of ' + orig_questionnaire.name questionnaire.created_at = Time.zone.now - questionnaire.updated_at = Time.zone.now questionnaire.save! - questions.each do |item| - new_question = item.dup + questions.each do |question| + new_question = question.dup new_question.questionnaire_id = questionnaire.id + new_question.size = '50,3' if (new_question.is_a?(Criterion) || new_question.is_a?(TextResponse)) && new_question.size.nil? new_question.save! + advices = QuestionAdvice.where(question_id: question.id) + next if advices.empty? + + advices.each do |advice| + new_advice = advice.dup + new_advice.question_id = new_question.id + new_advice.save! + end end questionnaire end - # validate the entries for this questionnaire - def validate_questionnaire - errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1 - errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0 - errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score - results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) - errors.add(:name, 'Questionnaire names must be unique.') if results.present? - end - # Check_for_question_associations checks if questionnaire has associated questions or not def check_for_question_associations if questions.any? @@ -53,4 +82,4 @@ def as_json(options = {}) hash['instructor'] ||= { id: nil, name: nil } end end -end +end \ No newline at end of file diff --git a/app/models/quiz_response_map.rb b/app/models/quiz_response_map.rb index bb320889e..e53873022 100644 --- a/app/models/quiz_response_map.rb +++ b/app/models/quiz_response_map.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class QuizResponseMap < ResponseMap + include ResponseMapSubclassTitles belongs_to :quiz_questionnaire, foreign_key: 'reviewed_object_id', inverse_of: false belongs_to :assignment, inverse_of: false has_many :quiz_responses, foreign_key: :map_id, dependent: :destroy, inverse_of: false diff --git a/app/models/response.rb b/app/models/response.rb index 9e07fd79d..1dd9ba045 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -8,7 +8,12 @@ class Response < ApplicationRecord has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false alias map response_map - delegate :questionnaire, :reviewee, :reviewer, to: :map + delegate :response_assignment, :reviewee, :reviewer, to: :map + + # return the questionnaire that belongs to the response + def questionnaire + response_assignment.assignment_questionnaires.find_by(used_in_round: self.round).questionnaire + end def reportable_difference? map_class = map.class @@ -46,14 +51,26 @@ def reportable_difference? end def aggregate_questionnaire_score - # only count the scorable questions, only when the answer is not nil - # we accept nil as answer for scorable questions, and they will not be counted towards the total score + # only count the scorable items, only when the answer is not nil + # we accept nil as answer for scorable items, and they will not be counted towards the total score sum = 0 scores.each do |s| - item = Item.find(s.question_id) # For quiz responses, the weights will be 1 or 0, depending on if correct - sum += s.answer * item.weight unless s.answer.nil? || !item.scorable? + sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable? end + # puts "sum: #{sum}" sum end -end + + # Returns the maximum possible score for this response + def maximum_score + # only count the scorable questions, only when the answer is not nil (we accept nil as + # answer for scorable questions, and they will not be counted towards the total score) + total_weight = 0 + scores.each do |s| + total_weight += s.item.weight unless s.answer.nil? #|| !s.item.is_a(ScoredItem)? + end + # puts "total: #{total_weight * questionnaire.max_question_score} " + total_weight * questionnaire.max_question_score + end +end \ No newline at end of file diff --git a/app/models/response_map.rb b/app/models/response_map.rb index 772e4fa30..5657741cf 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ResponseMap < ApplicationRecord - include ResponseMapSubclassTitles - - has_many :response, foreign_key: 'map_id', dependent: :destroy, inverse_of: false + has_many :responses, foreign_key: 'map_id', dependent: :destroy, inverse_of: false belongs_to :reviewer, class_name: 'Participant', foreign_key: 'reviewer_id', inverse_of: false belongs_to :reviewee, class_name: 'Participant', foreign_key: 'reviewee_id', inverse_of: false belongs_to :assignment, class_name: 'Assignment', foreign_key: 'reviewed_object_id', inverse_of: false @@ -16,7 +14,8 @@ def questionnaire # returns the assignment related to the response map def response_assignment - return Participant.find(self.reviewer_id).assignment + # reviewer will always be the Assignment Participant so finding Assignment based on reviewer_id. + return reviewer.assignment end def self.assessments_for(team) @@ -47,8 +46,37 @@ def self.assessments_for(team) responses end - # Check to see if this response map is a survey. Default is false, and some subclasses will overwrite to true. - def survey? - false + # Computes the average score (as a fraction between 0 and 1) across the latest submitted responses + # from each round for this ReviewResponseMap. + def aggregate_reviewers_score + # Return nil if there are no responses for this map + return nil if responses.empty? + + # Group all responses by round, then select the latest one per round based on the most recent created one (i.e., most recent revision in that round) + latest_responses_by_round = responses + .group_by(&:round) + .transform_values { |resps| resps.max_by(&:updated_at) } + + response_score = 0.0 # Sum of actual scores obtained + total_score = 0.0 # Sum of maximum possible scores + submitted_found = false #flag to track if any submitted response exists + + # For each latest response in each round, if the response was submitted, sum up its earned score and its maximum possible score. + latest_responses_by_round.each_value do |response| + # Only consider responses that were submitted + next unless response.is_submitted + + submitted_found = true # true if a submitted response is found + + # Accumulate the obtained and maximum scores + response_score += response.aggregate_questionnaire_score + total_score += response.maximum_score + end + + # If no submitted responses at all, return nil + return nil unless submitted_found + + # Return the normalized score (as a float), or 0 if no valid total score + total_score > 0 ? (response_score.to_f / total_score) : 0 end end diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index b792c73ef..c00955bcc 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ReviewResponseMap < ResponseMap + include ResponseMapSubclassTitles belongs_to :reviewee, class_name: 'Team', foreign_key: 'reviewee_id', inverse_of: false # returns the assignment related to the response map @@ -8,6 +9,10 @@ def response_assignment return assignment end + def questionnaire_type + 'Review' + end + def get_title REVIEW_RESPONSE_MAP_TITLE end diff --git a/app/models/scale.rb b/app/models/scale.rb index 1ab6b880a..2517f7cf7 100644 --- a/app/models/scale.rb +++ b/app/models/scale.rb @@ -27,6 +27,10 @@ def view_completed_item(options = {}) { message: 'Item not answered.' }.to_json end end + + def max_score + questionnaire.max_question_score * weight + end private diff --git a/app/models/score_view.rb b/app/models/score_view.rb new file mode 100644 index 000000000..fc3d9344e --- /dev/null +++ b/app/models/score_view.rb @@ -0,0 +1,13 @@ +class ScoreView < ApplicationRecord + # setting this to false so that factories can be created + # to test the grading of weighted quiz questionnaires + def readonly? + false + end + + def self.questionnaire_data(questionnaire_id, response_id) + questionnaire_data = ScoreView.find_by_sql ["SELECT q1_max_question_score ,SUM(question_weight) as sum_of_weights,SUM(question_weight * s_score) as weighted_score FROM score_views WHERE type in('Criterion', 'Scale') AND q1_id = ? AND s_response_id = ? GROUP BY q1_max_question_score", questionnaire_id, response_id] + questionnaire_data[0] + end + end + \ No newline at end of file diff --git a/app/models/scored_item.rb b/app/models/scored_item.rb index 6b8a36c33..e398e62d4 100644 --- a/app/models/scored_item.rb +++ b/app/models/scored_item.rb @@ -1,7 +1,15 @@ # frozen_string_literal: true class ScoredItem < ChoiceItem + validates :weight, presence: true # user must specify a weight for a question + validates :weight, numericality: true # the weight must be numeric + def scorable? true - end + end + + def self.compute_item_score(response_id) + answer = Answer.find_by(item_id: id, response_id: response_id) + weight * answer.answer + end end diff --git a/app/models/sign_up_topic.rb b/app/models/sign_up_topic.rb index 1d89b687b..4dec21d82 100644 --- a/app/models/sign_up_topic.rb +++ b/app/models/sign_up_topic.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class SignUpTopic < ApplicationRecord + include DueDateActions has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy - has_many :due_dates, as: :parent,class_name: 'DueDate', dependent: :destroy + # Note: due_dates association is provided by DueDateActions mixin belongs_to :assignment end diff --git a/app/models/ta.rb b/app/models/ta.rb index dd94963b5..9968b000c 100644 --- a/app/models/ta.rb +++ b/app/models/ta.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Ta < User + # Get all users whose parent is the TA # @return [Array] all users that belongs to courses that is mapped to the TA def managed_users @@ -10,4 +11,9 @@ def managed_users def my_instructor # code here end + + def courses_assisted_with + courses = TaMapping.where(ta_id: id) + courses.map { |c| Course.find(c.course_id) } + end end \ No newline at end of file diff --git a/app/models/team.rb b/app/models/team.rb index 08ce2b5d2..9c8813c08 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -4,7 +4,7 @@ class Team < ApplicationRecord # Core associations has_many :signed_up_teams, dependent: :destroy - has_many :teams_users, dependent: :destroy + has_many :teams_users, dependent: :destroy has_many :teams_participants, dependent: :destroy has_many :users, through: :teams_participants has_many :participants, through: :teams_participants @@ -13,7 +13,7 @@ class Team < ApplicationRecord belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id', optional: true belongs_to :course, class_name: 'Course', foreign_key: 'parent_id', optional: true belongs_to :user, optional: true # Team creator - + attr_accessor :max_participants validates :parent_id, presence: true validates :type, presence: true, inclusion: { in: %w[AssignmentTeam CourseTeam MentoredTeam], message: "must be 'Assignment' or 'Course' or 'Mentor'" } @@ -21,7 +21,7 @@ class Team < ApplicationRecord def has_member?(user) participants.exists?(user_id: user.id) end - + def full? current_size = participants.count diff --git a/app/models/teammate_review_response_map.rb b/app/models/teammate_review_response_map.rb index 88e07452c..c56eb1fb6 100644 --- a/app/models/teammate_review_response_map.rb +++ b/app/models/teammate_review_response_map.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true - -class TeammateReviewResponseMap < ReviewResponseMap - belongs_to :reviewee, class_name: 'Participant', foreign_key: 'reviewee_id' +class TeammateReviewResponseMap < ResponseMap + belongs_to :reviewee, class_name: 'Participant', foreign_key: 'reviewee_id', inverse_of: false belongs_to :assignment, class_name: 'Assignment', foreign_key: 'reviewed_object_id' + + def questionnaire_type + 'TeammateReview' + end + def questionnaire assignment.questionnaires.find_by(type: 'TeammateReviewQuestionnaire') end diff --git a/app/models/user.rb b/app/models/user.rb index 65dd59724..0e77e25dc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -82,6 +82,39 @@ def instructor_id end end + def can_impersonate?(user) + return true if role.super_admin? + return true if teaching_assistant_for?(user) + return true if recursively_parent_of(user) + + false + end + + def recursively_parent_of(user) + p = user.parent + return false if p.nil? + return true if p == self + return false if p.role.super_admin? + + recursively_parent_of(p) + end + + def teaching_assistant_for?(student) + return false unless teaching_assistant? + return false unless student.role.name == 'Student' + + # We have to use the Ta object instead of User object + # because single table inheritance is not currently functioning + ta = Ta.find(id) + return true if ta.courses_assisted_with.any? do |c| + c.assignments.map(&:participants).flatten.map(&:user_id).include? student.id + end + end + + def teaching_assistant? + true if role.ta? + end + def self.from_params(params) user = params[:user_id] ? User.find(params[:user_id]) : User.find_by(name: params[:user][:name]) raise "User #{params[:user_id] || params[:user][:name]} not found" if user.nil? diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index d58d33354..eece9d3bc 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TeamSerializer < ActiveModel::Serializer - attributes :id, :name, :max_team_size, :type, :team_size, :assignment_id + attributes :id, :name, :type, :team_size, :assignment_id has_many :users, serializer: UserSerializer def users diff --git a/config/database.yml b/config/database.yml index 7b9674459..b9f5aa055 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,57 +1,18 @@ -# MySQL. Versions 5.5.8 and up are supported. -# -# Install the MySQL driver -# gem install mysql2 -# -# Ensure the MySQL gem is defined in your Gemfile -# gem "mysql2" -# -# And be sure to use new-style password hashing: -# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html -# default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: dev - password: expertiza - host: <%= ENV.fetch("DB_HOST", "db") %> - port: <%= ENV.fetch("DB_PORT", "3306") %> - + port: 3306 + socket: /var/run/mysqld/mysqld.sock development: <<: *default - database: reimplementation_development + url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. test: <<: *default - database: reimplementation_test + url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> -# As with config/credentials.yml, you never want to store sensitive information, -# like your database password, in your source code. If your source code is -# ever seen by anyone, they now have access to your database. -# -# Instead, provide the password or a full connection URL as an environment -# variable when you boot the app. For example: -# -# DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase" -# -# If the connection URL is provided in the special DATABASE_URL environment -# variable, Rails will automatically merge its configuration values on top of -# the values provided in this file. Alternatively, you can specify a connection -# URL environment variable explicitly: -# -# production: -# url: <%= ENV["MY_APP_DATABASE_URL"] %> -# -# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database -# for a full overview on how database connection configuration can be specified. -# production: <<: *default - database: reimplementation_production - username: reimplementation - password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %> \ No newline at end of file + url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file diff --git a/config/database.yml.old b/config/database.yml.old index b9f5aa055..7b9674459 100644 --- a/config/database.yml.old +++ b/config/database.yml.old @@ -1,18 +1,57 @@ +# MySQL. Versions 5.5.8 and up are supported. +# +# Install the MySQL driver +# gem install mysql2 +# +# Ensure the MySQL gem is defined in your Gemfile +# gem "mysql2" +# +# And be sure to use new-style password hashing: +# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html +# default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - port: 3306 - socket: /var/run/mysqld/mysqld.sock + username: dev + password: expertiza + host: <%= ENV.fetch("DB_HOST", "db") %> + port: <%= ENV.fetch("DB_PORT", "3306") %> + development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="mysql2://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production + username: reimplementation + password: <%= ENV["REIMPLEMENTATION_DATABASE_PASSWORD"] %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..25642363c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -90,8 +90,6 @@ end end - - resources :sign_up_topics do collection do get :filter @@ -140,5 +138,15 @@ post :add_participant delete :delete_participants end + end + resources :grades do + collection do + get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' + patch '/:participant_id/assign_grade', to: 'grades#assign_grade' + get '/:participant_id/edit', to: 'grades#edit' + get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' + get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' + get '/:participant_id/instructor_review', to: 'grades#instructor_review' + end end -end +end \ No newline at end of file diff --git a/db/migrate/20231102173152_create_teams.rb b/db/migrate/20231102173152_create_teams.rb index ab6a5ce3a..699e0c56e 100644 --- a/db/migrate/20231102173152_create_teams.rb +++ b/db/migrate/20231102173152_create_teams.rb @@ -4,10 +4,11 @@ class CreateTeams < ActiveRecord::Migration[8.0] def change create_table :teams do |t| t.string :name, null: false + t.integer :parent_id, index: true t.string :type, null: false - t.integer :max_team_size, null: false, default: 5 - t.references :user, foreign_key: true - t.references :mentor, foreign_key: { to_table: :users } + t.boolean :advertise_for_partner, null: false, default: false + t.text :submitted_hyperlinks + t.integer :directory_num t.timestamps end diff --git a/db/migrate/20231130033226_create_teams_users.rb b/db/migrate/20231130033226_create_teams_users.rb index ee66c0e2a..4a9528789 100644 --- a/db/migrate/20231130033226_create_teams_users.rb +++ b/db/migrate/20231130033226_create_teams_users.rb @@ -8,5 +8,6 @@ def change t.timestamps end + add_index :teams_users, [:team_id, :user_id], unique: true end end diff --git a/db/migrate/20241201000001_create_deadline_types.rb b/db/migrate/20241201000001_create_deadline_types.rb new file mode 100644 index 000000000..81db538b6 --- /dev/null +++ b/db/migrate/20241201000001_create_deadline_types.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class CreateDeadlineTypes < ActiveRecord::Migration[7.0] + def change + create_table :deadline_types do |t| + t.string :name, null: false + t.text :description, null: false + + t.timestamps + end + + add_index :deadline_types, :name, unique: true + + # Add foreign key constraint to due_dates table + add_foreign_key :due_dates, :deadline_types, column: :deadline_type_id + + # Seed canonical deadline type data + reversible do |dir| + dir.up do + deadline_types = [ + { id: 1, name: 'submission', description: 'Student work submission deadlines' }, + { id: 2, name: 'review', description: 'Peer review deadlines' }, + { id: 3, name: 'teammate_review', description: 'Team member evaluation deadlines' }, + { id: 5, name: 'metareview', description: 'Meta-review deadlines (kept for backward compatibility)' }, + { id: 6, name: 'drop_topic', description: 'Topic drop deadlines' }, + { id: 7, name: 'signup', description: 'Course/assignment signup deadlines' }, + { id: 8, name: 'team_formation', description: 'Team formation deadlines' }, + { id: 11, name: 'quiz', description: 'Quiz completion deadlines' } + ] + + deadline_types.each do |type_attrs| + execute <<~SQL + INSERT INTO deadline_types (id, name, description, created_at, updated_at) + VALUES (#{type_attrs[:id]}, '#{type_attrs[:name]}', '#{type_attrs[:description]}', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW() + SQL + end + + # Clean up any duplicate team_formation entries (keeping ID 8) + execute <<~SQL + DELETE FROM deadline_types + WHERE name = 'team_formation' AND id != 8 + SQL + + # Update any due_dates that might reference the duplicate ID + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 8 + WHERE deadline_type_id IN ( + SELECT id FROM deadline_types + WHERE name = 'team_formation' AND id != 8 + ) + SQL + end + + dir.down do + # Remove foreign key constraint before dropping table + remove_foreign_key :due_dates, :deadline_types + end + end + end +end diff --git a/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb b/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb new file mode 100644 index 000000000..fadb0b506 --- /dev/null +++ b/db/migrate/20241201000002_add_deadline_type_foreign_key_to_due_dates.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class AddDeadlineTypeForeignKeyToDueDates < ActiveRecord::Migration[7.0] + def change + # Ensure deadline_type_id column exists and is properly typed + unless column_exists?(:due_dates, :deadline_type_id) + add_column :due_dates, :deadline_type_id, :integer, null: false + end + + # Clean up any invalid deadline_type_id references before adding foreign key + reversible do |dir| + dir.up do + # Update any due_dates with invalid deadline_type_id to use submission (ID: 1) + # This handles orphaned records that might reference non-existent deadline types + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 1 + WHERE deadline_type_id NOT IN (1, 2, 3, 5, 6, 7, 8, 11) + OR deadline_type_id IS NULL + SQL + + # Clean up any duplicate team_formation references (use canonical ID 8) + execute <<~SQL + UPDATE due_dates + SET deadline_type_id = 8 + WHERE deadline_type_id = 10 + SQL + end + end + + # Add foreign key constraint to ensure referential integrity + add_foreign_key :due_dates, :deadline_types, column: :deadline_type_id, + on_delete: :restrict, on_update: :cascade + + # Add index for better query performance + add_index :due_dates, :deadline_type_id unless index_exists?(:due_dates, :deadline_type_id) + + # Add composite index for common query patterns + add_index :due_dates, [:parent_type, :parent_id, :deadline_type_id], + name: 'index_due_dates_on_parent_and_deadline_type' unless + index_exists?(:due_dates, [:parent_type, :parent_id, :deadline_type_id]) + end +end diff --git a/db/migrate/20241201000003_create_deadline_rights.rb b/db/migrate/20241201000003_create_deadline_rights.rb new file mode 100644 index 000000000..5afc6732f --- /dev/null +++ b/db/migrate/20241201000003_create_deadline_rights.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CreateDeadlineRights < ActiveRecord::Migration[7.0] + def change + create_table :deadline_rights do |t| + t.string :name, null: false + t.text :description, null: false + + t.timestamps + end + + add_index :deadline_rights, :name, unique: true + + # Seed the deadline rights with canonical data + reversible do |dir| + dir.up do + deadline_rights = [ + { id: 1, name: 'No', description: 'Action is not allowed' }, + { id: 2, name: 'Late', description: 'Action is allowed with late penalty' }, + { id: 3, name: 'OK', description: 'Action is allowed without penalty' } + ] + + deadline_rights.each do |right_attrs| + execute <<~SQL + INSERT INTO deadline_rights (id, name, description, created_at, updated_at) + VALUES (#{right_attrs[:id]}, '#{right_attrs[:name]}', '#{right_attrs[:description]}', NOW(), NOW()) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + updated_at = NOW() + SQL + end + end + + dir.down do + # Remove all deadline rights + execute "DELETE FROM deadline_rights" + end + end + end +end diff --git a/db/migrate/20250621151644_add_round_to_responses.rb b/db/migrate/20250621151644_add_round_to_responses.rb new file mode 100644 index 000000000..79d5354fe --- /dev/null +++ b/db/migrate/20250621151644_add_round_to_responses.rb @@ -0,0 +1,5 @@ +class AddRoundToResponses < ActiveRecord::Migration[8.0] + def change + add_column :responses, :round, :integer + end +end diff --git a/db/migrate/20250621152946_add_version_number_to_responses.rb b/db/migrate/20250621152946_add_version_number_to_responses.rb new file mode 100644 index 000000000..a187f3c11 --- /dev/null +++ b/db/migrate/20250621152946_add_version_number_to_responses.rb @@ -0,0 +1,5 @@ +class AddVersionNumberToResponses < ActiveRecord::Migration[8.0] + def change + add_column :responses, :version_num, :integer + end +end diff --git a/db/migrate/20250621180527_change_question_to_item_in_answers.rb b/db/migrate/20250621180527_change_question_to_item_in_answers.rb new file mode 100644 index 000000000..fdb3a5d3a --- /dev/null +++ b/db/migrate/20250621180527_change_question_to_item_in_answers.rb @@ -0,0 +1,5 @@ +class ChangeQuestionToItemInAnswers < ActiveRecord::Migration[8.0] + def change + rename_column :answers, :question_id, :item_id + end +end diff --git a/db/migrate/20250621180851_rename_item_foreign_key_index_in_answers.rb b/db/migrate/20250621180851_rename_item_foreign_key_index_in_answers.rb new file mode 100644 index 000000000..b9bdfabf9 --- /dev/null +++ b/db/migrate/20250621180851_rename_item_foreign_key_index_in_answers.rb @@ -0,0 +1,6 @@ +class RenameItemForeignKeyIndexInAnswers < ActiveRecord::Migration[8.0] + def change + remove_index :answers, name: "fk_score_questions" + add_index :answers, :item_id, name: "fk_score_items" + end +end diff --git a/db/migrate/20250626161114_add_name_to_teams.rb b/db/migrate/20250626161114_add_name_to_teams.rb new file mode 100644 index 000000000..d18f94bcd --- /dev/null +++ b/db/migrate/20250626161114_add_name_to_teams.rb @@ -0,0 +1,5 @@ +class AddNameToTeams < ActiveRecord::Migration[8.0] + def change + add_column :teams, :name, :string + end +end diff --git a/db/migrate/20250629185100_add_grade_to_participant.rb b/db/migrate/20250629185100_add_grade_to_participant.rb new file mode 100644 index 000000000..4ef9bdc96 --- /dev/null +++ b/db/migrate/20250629185100_add_grade_to_participant.rb @@ -0,0 +1,5 @@ +class AddGradeToParticipant < ActiveRecord::Migration[8.0] + def change + add_column :participants, :grade, :float + end +end diff --git a/db/migrate/20250629185439_add_grade_for_submission_to_team.rb b/db/migrate/20250629185439_add_grade_for_submission_to_team.rb new file mode 100644 index 000000000..83919e338 --- /dev/null +++ b/db/migrate/20250629185439_add_grade_for_submission_to_team.rb @@ -0,0 +1,5 @@ +class AddGradeForSubmissionToTeam < ActiveRecord::Migration[8.0] + def change + add_column :teams, :grade_for_submission, :integer + end +end diff --git a/db/migrate/20250629190818_add_comment_for_submission_to_team.rb b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb new file mode 100644 index 000000000..b2c6c5eb4 --- /dev/null +++ b/db/migrate/20250629190818_add_comment_for_submission_to_team.rb @@ -0,0 +1,5 @@ +class AddCommentForSubmissionToTeam < ActiveRecord::Migration[8.0] + def change + add_column :teams, :comment_for_submission, :string + end +end diff --git a/db/migrate/20250727170825_add_questionnaire_weight_to_assignment_questionnaire.rb b/db/migrate/20250727170825_add_questionnaire_weight_to_assignment_questionnaire.rb new file mode 100644 index 000000000..e5a6b7ca0 --- /dev/null +++ b/db/migrate/20250727170825_add_questionnaire_weight_to_assignment_questionnaire.rb @@ -0,0 +1,5 @@ +class AddQuestionnaireWeightToAssignmentQuestionnaire < ActiveRecord::Migration[8.0] + def change + add_column :assignment_questionnaires, :questionnaire_weight, :integer + end +end diff --git a/db/migrate/20251028_add_team_to_review_mappings.rb b/db/migrate/20251028_add_team_to_review_mappings.rb new file mode 100644 index 000000000..8a80254cd --- /dev/null +++ b/db/migrate/20251028_add_team_to_review_mappings.rb @@ -0,0 +1,12 @@ +class AddTeamToReviewMappings < ActiveRecord::Migration[7.1] + def change + # If this app doesn't have review_mappings yet, just skip. + return unless table_exists?(:review_mappings) + + # Only add the column if it's missing. + unless column_exists?(:review_mappings, :team_id) + add_reference :review_mappings, :team, foreign_key: true, null: true + add_index :review_mappings, :team_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 462029322..d3a15fcfa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_27_014225) do +ActiveRecord::Schema[8.0].define(version: 2025_10_29_071649) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -26,13 +26,13 @@ end create_table "answers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.integer "question_id", default: 0, null: false + t.integer "item_id", default: 0, null: false t.integer "response_id" t.integer "answer" t.text "comments" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["question_id"], name: "fk_score_questions" + t.index ["item_id"], name: "fk_score_items" t.index ["response_id"], name: "fk_score_response" end @@ -43,6 +43,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "used_in_round" + t.integer "questionnaire_weight" t.index ["assignment_id"], name: "fk_aq_assignments_id" t.index ["questionnaire_id"], name: "fk_aq_questionnaire_id" end @@ -124,6 +125,11 @@ t.datetime "updated_at", null: false end + create_table "cakes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "directory_path" @@ -170,14 +176,16 @@ create_table "invitations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" - t.integer "from_id" - t.integer "to_id" t.string "reply_status", limit: 1 t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "from_id", null: false + t.bigint "to_id", null: false + t.bigint "participant_id", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" - t.index ["from_id"], name: "fk_invitationfrom_users" - t.index ["to_id"], name: "fk_invitationto_users" + t.index ["from_id"], name: "index_invitations_on_from_id" + t.index ["participant_id"], name: "index_invitations_on_participant_id" + t.index ["to_id"], name: "index_invitations_on_to_id" end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -203,7 +211,7 @@ t.integer "participant_id" t.integer "team_id" t.text "comments" - t.string "status" + t.string "reply_status" end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -232,6 +240,7 @@ t.string "authorization" t.integer "parent_id", null: false t.string "type", null: false + t.float "grade" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" @@ -286,6 +295,7 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "type" t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -295,6 +305,8 @@ t.boolean "is_submitted", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "round" + t.integer "version_num" t.index ["map_id"], name: "fk_response_response_map" end @@ -330,6 +342,8 @@ t.integer "preference_priority_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "comments_for_advertisement" + t.boolean "advertise_for_partner" t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end @@ -347,15 +361,11 @@ create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "type", null: false - t.integer "max_team_size", default: 5, null: false - t.bigint "user_id" - t.bigint "mentor_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "parent_id", null: false - t.index ["mentor_id"], name: "index_teams_on_mentor_id" - t.index ["type"], name: "index_teams_on_type" - t.index ["user_id"], name: "index_teams_on_user_id" + t.integer "grade_for_submission" + t.string "comment_for_submission" end create_table "teams_participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -412,6 +422,8 @@ add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "invitations", "participants", column: "from_id" + add_foreign_key "invitations", "participants", column: "to_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" @@ -423,8 +435,6 @@ add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" - add_foreign_key "teams", "users" - add_foreign_key "teams", "users", column: "mentor_id" add_foreign_key "teams_participants", "participants" add_foreign_key "teams_participants", "teams" add_foreign_key "teams_users", "teams" diff --git a/db/seeds.rb b/db/seeds.rb index 9828977ea..8c11894f0 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,130 +1,128 @@ # frozen_string_literal: true begin - #Create an instritution - inst_id = Institution.create!( - name: 'North Carolina State University', + # Create an instritution + inst_id = Institution.create!( + name: 'North Carolina State University' + ).id + + Role.create!(id: 1, name: 'Super Administrator') + Role.create!(id: 2, name: 'Administrator') + Role.create!(id: 3, name: 'Instructor') + Role.create!(id: 4, name: 'Teaching Assistant') + Role.create!(id: 5, name: 'Student') + + # Create an admin user + User.create!( + name: 'admin', + email: 'admin2@example.com', + password: 'password123', + full_name: 'admin admin', + institution_id: 1, + role_id: 1 + ) + + # Generate Random Users + num_students = 48 + num_assignments = 8 + num_teams = 16 + num_courses = 2 + num_instructors = 2 + + puts "creating instructors" + instructor_user_ids = [] + num_instructors.times do + instructor_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, + institution_id: 1, + role_id: 3 + ).id + end + + puts "creating courses" + course_ids = [] + num_courses.times do |i| + course_ids << Course.create( + instructor_id: instructor_user_ids[i], + institution_id: inst_id, + directory_path: Faker::File.dir(segment_count: 2), + name: Faker::Company.industry, + info: "A fake class", + private: false + ).id + end + + puts "creating assignments" + assignment_ids = [] + num_assignments.times do |i| + assignment_ids << Assignment.create( + name: Faker::Verb.base, + instructor_id: instructor_user_ids[i % num_instructors], + course_id: course_ids[i % num_courses], + has_teams: true, + private: false + ).id + end + + puts "creating teams" + team_ids = [] + num_teams.times do |i| + team_ids << AssignmentTeam.create( + name: "Team #{i + 1}", + parent_id: assignment_ids[i % num_assignments] ).id - - # Create an admin user - User.create!( - name: 'admin', - email: 'admin2@example.com', - password: 'password123', - full_name: 'admin admin', + end + + puts "creating students" + student_user_ids = [] + num_students.times do + student_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, institution_id: 1, - role_id: 1 + role_id: 5, + parent_id: [nil, *instructor_user_ids].sample + ).id + end + + puts "assigning students to teams" + teams_users_ids = [] + # num_students.times do |i| + # teams_users_ids << TeamsUser.create( + # team_id: team_ids[i%num_teams], + # user_id: student_user_ids[i] + # ).id + # end + + num_students.times do |i| + puts "Creating TeamsUser with team_id: #{team_ids[i % num_teams]}, user_id: #{student_user_ids[i]}" + teams_user = TeamsUser.create( + team_id: team_ids[i % num_teams], + user_id: student_user_ids[i] ) - - - #Generate Random Users - num_students = 48 - num_assignments = 8 - num_teams = 16 - num_courses = 2 - num_instructors = 2 - - puts "creating instructors" - instructor_user_ids = [] - num_instructors.times do - instructor_user_ids << User.create( - name: Faker::Internet.unique.username, - email: Faker::Internet.unique.email, - password: "password", - full_name: Faker::Name.name, - institution_id: 1, - role_id: 3, - ).id - end - - puts "creating courses" - course_ids = [] - num_courses.times do |i| - course_ids << Course.create( - instructor_id: instructor_user_ids[i], - institution_id: inst_id, - directory_path: Faker::File.dir(segment_count: 2), - name: Faker::Company.industry, - info: "A fake class", - private: false - ).id - end - - puts "creating assignments" - assignment_ids = [] - num_assignments.times do |i| - assignment_ids << Assignment.create( - name: Faker::Verb.base, - instructor_id: instructor_user_ids[i%num_instructors], - course_id: course_ids[i%num_courses], - has_teams: true, - private: false - ).id - end - - - puts "creating teams" - team_ids = [] - num_teams.times do |i| - team_ids << AssignmentTeam.create( - name: "Team #{i + 1}", - parent_id: assignment_ids[i%num_assignments] - ).id - end - - puts "creating students" - student_user_ids = [] - num_students.times do - student_user_ids << User.create( - name: Faker::Internet.unique.username, - email: Faker::Internet.unique.email, - password: "password", - full_name: Faker::Name.name, - institution_id: 1, - role_id: 5, - ).id - end - - puts "assigning students to teams" - teams_users_ids = [] - #num_students.times do |i| - # teams_users_ids << TeamsUser.create( - # team_id: team_ids[i%num_teams], - # user_id: student_user_ids[i] - # ).id - #end - - num_students.times do |i| - puts "Creating TeamsUser with team_id: #{team_ids[i % num_teams]}, user_id: #{student_user_ids[i]}" - teams_user = TeamsUser.create( - team_id: team_ids[i % num_teams], - user_id: student_user_ids[i] - ) - if teams_user.persisted? - teams_users_ids << teams_user.id - puts "Created TeamsUser with ID: #{teams_user.id}" - else - puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" - end + if teams_user.persisted? + teams_users_ids << teams_user.id + puts "Created TeamsUser with ID: #{teams_user.id}" + else + puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" end - - puts "assigning participant to students, teams, courses, and assignments" - participant_ids = [] - num_students.times do |i| - participant_ids << AssignmentParticipant.create( - user_id: student_user_ids[i], - parent_id: assignment_ids[i%num_assignments], - team_id: team_ids[i%num_teams], - ).id - end - - - - - - - + end + + puts "assigning participant to students, teams, courses, and assignments" + participant_ids = [] + num_students.times do |i| + participant_ids << AssignmentParticipant.create( + user_id: student_user_ids[i], + parent_id: assignment_ids[i%num_assignments], + team_id: team_ids[i%num_teams], + ).id + end rescue ActiveRecord::RecordInvalid => e - puts 'The db has already been seeded' + puts e, 'The db has already been seeded' end diff --git a/db/seeds2.rb b/db/seeds2.rb new file mode 100644 index 000000000..908901b92 --- /dev/null +++ b/db/seeds2.rb @@ -0,0 +1,53 @@ +# begin +# questionnaire_count = 2 +# items_per_questionnaire = 10 +# questionnaire_ids = [] +# questionnaire_count.times do +# questionnaire_ids << Questionnaire.create!( +# name: "#{Faker::Lorem.words(number: 5).join(' ').titleize}", +# instructor_id: rand(1..5), # assuming some instructor IDs exist in range 1–5 +# private: false, +# min_question_score: 0, +# max_question_score: 5, +# questionnaire_type: "ReviewQuestionnaire", +# display_type: "Review", +# created_at: Time.now, +# updated_at: Time.now +# ).id + +# end +# puts questionnaire_ids + +# questionnaires = Questionnaire.all + +# questionnaires.each do |questionnaire| +# items_per_questionnaire.times do |i| +# Item.create!( +# txt: Faker::Lorem.sentence(word_count: 8), +# weight: rand(1..5), +# seq: i + 1, +# question_type: ['Criterion', 'Scale', 'TextArea', 'Dropdown'].sample, +# size: ['50x3', '60x4', '40x2'].sample, +# alternatives: ['Yes|No', 'Strongly Agree|Agree|Neutral|Disagree|Strongly Disagree'], +# break_before: true, +# max_label: Faker::Lorem.word.capitalize, +# min_label: Faker::Lorem.word.capitalize, +# questionnaire_id: questionnaire.id, +# created_at: Time.now, +# updated_at: Time.now +# ) +# end +# end + +# end + +begin + count = 4 + count.times do |i| + AssignmentQuestionnaire.create!( + assignment_id: i+1, + questionnaire_id: i+1, + used_in_round: [1,2].sample + ) + end +end diff --git a/lib/tasks/deadline_demo.rake b/lib/tasks/deadline_demo.rake new file mode 100644 index 000000000..af5dda5e0 --- /dev/null +++ b/lib/tasks/deadline_demo.rake @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +namespace :deadline do + desc "Demonstrate the new DeadlineType and DueDate functionality" + task demo: :environment do + puts "=" * 80 + puts "DeadlineType and DueDate Refactoring Demo" + puts "=" * 80 + puts + + # Step 1: Seed deadline types and rights + puts "1. Setting up DeadlineTypes and DeadlineRights..." + DeadlineType.seed_deadline_types! + DeadlineRight.seed_deadline_rights! + DeadlineType.cleanup_duplicates! + + puts " Created #{DeadlineType.count} deadline types:" + DeadlineType.all.order(:id).each do |dt| + puts " - #{dt.name} (ID: #{dt.id}): #{dt.description}" + end + puts + + puts " Created #{DeadlineRight.count} deadline rights:" + DeadlineRight.all.order(:id).each do |dr| + puts " - #{dr.name} (ID: #{dr.id}): #{dr.description}" + end + puts + + # Step 2: Create a demo assignment + puts "2. Creating demo assignment..." + assignment = Assignment.create!( + name: "Demo Assignment - DueDate Refactor", + description: "Demonstration of the new deadline system", + max_team_size: 3, + instructor: User.find_by(role: Role.find_by(name: 'Instructor')) || User.first + ) + + puts " Created assignment: #{assignment.name} (ID: #{assignment.id})" + puts + + # Step 3: Create due dates using the new API + puts "3. Creating due dates using new DeadlineType integration..." + + submission_deadline = assignment.create_due_date( + 'submission', + 2.weeks.from_now, + submission_allowed_id: DeadlineRight::OK, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + review_deadline = assignment.create_due_date( + 'review', + 3.weeks.from_now, + submission_allowed_id: DeadlineRight::LATE, + review_allowed_id: DeadlineRight::OK, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + teammate_review_deadline = assignment.create_due_date( + 'teammate_review', + 4.weeks.from_now, + submission_allowed_id: DeadlineRight::NO, + review_allowed_id: DeadlineRight::LATE, + teammate_review_allowed_id: DeadlineRight::OK, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + quiz_deadline = assignment.create_due_date( + 'quiz', + 1.week.from_now, + submission_allowed_id: DeadlineRight::NO, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::OK, + round: 1 + ) + + puts " Created #{assignment.due_dates.count} deadlines for the assignment" + puts + + # Step 4: Demonstrate DeadlineType semantic methods + puts "4. DeadlineType semantic methods demo:" + puts " submission.submission? = #{DeadlineType.submission.submission?}" + puts " submission.review? = #{DeadlineType.submission.review?}" + puts " review.review? = #{DeadlineType.review.review?}" + puts " teammate_review.review? = #{DeadlineType.teammate_review.review?}" + puts " quiz.allows_quiz? = #{DeadlineType.quiz.allows_quiz?}" + puts " DeadlineType.for_action('submit') = #{DeadlineType.for_action('submit')&.name}" + puts " DeadlineType.for_action('review') = #{DeadlineType.for_action('review')&.name}" + puts + + # Step 5: Demonstrate DueDate instance methods + puts "5. DueDate instance methods demo:" + assignment.due_dates.each do |due_date| + puts " #{due_date.deadline_type_name.ljust(15)} | #{due_date.time_description} | Status: #{due_date.status_description}" + end + puts + + # Step 6: Demonstrate permission checking + puts "6. Permission checking demo:" + puts " Assignment permissions summary:" + permissions = assignment.action_permissions_summary + permissions.each do |action, allowed| + status = allowed ? "✓ ALLOWED" : "✗ DENIED" + puts " #{action.to_s.ljust(15)} : #{status}" + end + puts + + # Step 7: Demonstrate deadline queries + puts "7. Deadline query methods demo:" + puts " Next due date: #{assignment.next_due_date&.summary&.dig(:deadline_type) || 'None'}" + puts " Upcoming deadlines: #{assignment.upcoming_deadlines.count}" + puts " Overdue deadlines: #{assignment.overdue_deadlines.count}" + puts " Has future deadlines: #{assignment.has_future_deadlines?}" + puts " Used deadline types: #{assignment.used_deadline_types.map(&:name).join(', ')}" + puts + + # Step 8: Demonstrate workflow stage tracking + puts "8. Workflow stage tracking demo:" + puts " Current workflow stage: #{assignment.current_workflow_stage}" + puts " Workflow stages: #{assignment.workflow_stages.join(' → ')}" + puts " Stage completion status:" + assignment.stage_completion_status.each do |stage_info| + status = stage_info[:completed] ? "✓ COMPLETED" : "○ PENDING" + puts " #{stage_info[:stage].ljust(15)} : #{status}" + end + puts + + # Step 9: Demonstrate deadline copying + puts "9. Deadline copying demo:" + copied_assignment = assignment.copy + puts " Original assignment deadlines: #{assignment.due_dates.count}" + puts " Copied assignment deadlines: #{copied_assignment.due_dates.count}" + puts " Copy successful: #{assignment.due_dates.count == copied_assignment.due_dates.count}" + puts + + # Step 10: Demonstrate topic-specific deadlines + puts "10. Topic-specific deadline demo..." + + # Create a sign up topic + topic = SignUpTopic.create!( + topic_name: "Demo Topic", + topic_identifier: "DEMO-001", + max_choosers: 5, + assignment: assignment + ) + + # Create topic-specific deadline that differs from assignment + topic_submission = topic.create_due_date( + 'submission', + 10.days.from_now, # Different from assignment deadline + submission_allowed_id: DeadlineRight::OK, + review_allowed_id: DeadlineRight::NO, + teammate_review_allowed_id: DeadlineRight::NO, + quiz_allowed_id: DeadlineRight::NO, + round: 1 + ) + + puts " Created topic: #{topic.topic_name}" + puts " Topic submission deadline: #{topic_submission.due_at.strftime('%B %d, %Y')}" + puts " Assignment submission deadline: #{submission_deadline.due_at.strftime('%B %d, %Y')}" + puts " Topic has deadline overrides: #{assignment.has_topic_deadline_overrides?}" + + # Demonstrate topic deadline resolution + effective_deadline = assignment.effective_deadline_for_topic(topic.id, 'submission') + puts " Effective deadline for topic: #{effective_deadline.due_at.strftime('%B %d, %Y')} (#{effective_deadline.parent_type})" + puts + + # Step 11: Demonstrate deadline conflicts detection + puts "11. Deadline conflict detection demo:" + conflicts = assignment.deadline_conflicts_for_topic(topic.id) + if conflicts.any? + puts " Found #{conflicts.count} deadline conflicts:" + conflicts.each do |conflict| + puts " #{conflict[:deadline_type]}: Assignment (#{conflict[:assignment_due].strftime('%m/%d')}) vs Topic (#{conflict[:topic_due].strftime('%m/%d')})" + end + else + puts " No deadline conflicts detected" + end + puts + + # Step 12: Demonstrate deadline validation + puts "12. Deadline validation demo:" + puts " Deadlines properly ordered: #{assignment.deadlines_properly_ordered?}" + violations = assignment.deadline_ordering_violations + if violations.any? + puts " Ordering violations found:" + violations.each do |violation| + puts " #{violation[:issue]}" + end + else + puts " No ordering violations found" + end + puts + + # Step 13: Demonstrate permission status details + puts "13. Permission status details demo:" + assignment.due_dates.includes(:deadline_type).each do |due_date| + puts " #{due_date.deadline_type_name.ljust(15)}:" + puts " Submission: #{due_date.permission_description_for(:submission)}" + puts " Review: #{due_date.permission_description_for(:review)}" + puts " Quiz: #{due_date.permission_description_for(:quiz)}" + end + puts + + # Step 14: Demonstrate collection statistics + puts "14. Collection statistics demo:" + stats = DueDate.collection_stats(assignment.due_dates) + puts " Total deadlines: #{stats[:total]}" + puts " Upcoming: #{stats[:upcoming]}" + puts " Overdue: #{stats[:overdue]}" + puts " Due today: #{stats[:due_today]}" + puts " Currently active: #{stats[:active]}" + puts " Deadline types: #{stats[:types].join(', ')}" + puts + + # Step 15: Clean up + puts "15. Cleaning up demo data..." + copied_assignment.destroy + assignment.destroy + puts " Demo completed successfully!" + puts + puts "=" * 80 + puts "Summary of New Features Demonstrated:" + puts "- ✓ DeadlineType model as canonical source of truth" + puts "- ✓ Semantic helper methods for deadline types" + puts "- ✓ DueDate refactored with instance methods" + puts "- ✓ Permission checking through mixins" + puts "- ✓ Unified deadline querying" + puts "- ✓ Topic-specific deadline overrides" + puts "- ✓ Workflow stage tracking" + puts "- ✓ Deadline copying and duplication" + puts "- ✓ Conflict detection and validation" + puts "- ✓ Comprehensive permission status" + puts "=" * 80 + end + + desc "Clean up any duplicate deadline types" + task cleanup_duplicates: :environment do + puts "Cleaning up duplicate deadline types..." + DeadlineType.cleanup_duplicates! + puts "Cleanup completed." + end + + desc "Seed deadline types and rights" + task seed: :environment do + puts "Seeding deadline types..." + DeadlineType.seed_deadline_types! + puts "Seeding deadline rights..." + DeadlineRight.seed_deadline_rights! + puts "Seeding completed." + end + + desc "Show deadline statistics" + task stats: :environment do + puts "Deadline System Statistics" + puts "=" * 50 + puts "DeadlineTypes: #{DeadlineType.count}" + DeadlineType.all.each do |dt| + puts " #{dt.name}: #{dt.due_dates_count} due dates" + end + puts + puts "DeadlineRights: #{DeadlineRight.count}" + DeadlineRight.all.each do |dr| + puts " #{dr.name}: #{dr.usage_count} usages" + end + puts + puts "DueDates: #{DueDate.count}" + puts " Upcoming: #{DueDate.upcoming.count}" + puts " Overdue: #{DueDate.overdue.count}" + puts " Due today: #{DueDate.today.count}" + puts + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 857b81f50..1556b02b2 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -32,6 +32,10 @@ role { create(:role, :instructor) } end + trait :ta do + role { create(:role, :ta) } + end + trait :student do role { create(:role, :student) } end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 6c854b9c0..8533a6b0e 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -4,8 +4,6 @@ factory :team do name { Faker::Team.name } type { 'CourseTeam' } - max_team_size { 5 } - association :user, factory: :user trait :with_assignment do association :assignment, factory: :assignment @@ -15,16 +13,12 @@ factory :course_team, class: 'CourseTeam' do name { Faker::Team.name } type { 'CourseTeam' } - max_team_size { 5 } - association :user, factory: :user association :course, factory: :course end factory :assignment_team, class: 'AssignmentTeam' do name { Faker::Team.name } type { 'AssignmentTeam' } - max_team_size { 5 } - association :user, factory: :user transient do course { create(:course) } @@ -36,14 +30,12 @@ else team.course = team.assignment.course end - team.user ||= create(:user) end trait :with_assignment do after(:build) do |team, evaluator| team.assignment = create(:assignment, course: evaluator.course) team.course = team.assignment.course - team.user ||= create(:user) end end end @@ -51,8 +43,6 @@ factory :mentored_team, class: 'MentoredTeam' do name { Faker::Team.name } type { 'MentoredTeam' } - max_team_size { 5 } - association :user, factory: :user transient do course { create(:course) } @@ -60,11 +50,11 @@ assignment { create(:assignment, course: course) } - after(:build) do |team, evaluator| - mentor_role = create(:role, :mentor) - mentor = create(:user, role: mentor_role) - team.mentor = mentor - end + # after(:build) do |team, evaluator| + # mentor_role = create(:role, :mentor) + # mentor = create(:user, role: mentor_role) + # team.mentor = mentor + # end end factory :teams_participant, class: 'TeamsParticipant' do diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index 7724ac387..58fd6a45f 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -8,8 +8,8 @@ let(:team) {Team.new} let(:assignment) { Assignment.new(id: 1, name: 'Test Assignment') } let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } - let(:answer) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } - let(:answer2) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } + let(:answer) { Answer.new(answer: 1, comments: 'Answer text', item_id: 1) } + let(:answer2) { Answer.new(answer: 1, comments: 'Answer text', item_id: 1) } describe '.get_all_review_comments' do it 'returns concatenated review comments and # of reviews in each round' do @@ -19,7 +19,7 @@ .with(no_args).and_yield(review_response_map) response1 = double('Response', round: 1, additional_comment: '') response2 = double('Response', round: 2, additional_comment: 'LGTM') - allow(review_response_map).to receive(:response).and_return([response1, response2]) + allow(review_response_map).to receive(:responses).and_return([response1, response2]) allow(response1).to receive(:scores).and_return([answer]) allow(response2).to receive(:scores).and_return([answer2]) expect(assignment.get_all_review_comments(1)).to eq([[nil, 'Answer text', 'Answer textLGTM', ''], [nil, 1, 1, 0]]) diff --git a/spec/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb index 4dab54d2e..50e03616f 100644 --- a/spec/models/assignment_team_spec.rb +++ b/spec/models/assignment_team_spec.rb @@ -65,7 +65,6 @@ def create_student(suffix) AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end @@ -111,7 +110,6 @@ def create_student(suffix) describe 'associations' do it { should belong_to(:assignment) } - it { should belong_to(:user).optional } it { should have_many(:teams_participants).dependent(:destroy) } it { should have_many(:users).through(:teams_participants) } end diff --git a/spec/models/course_team_spec.rb b/spec/models/course_team_spec.rb index f3758fe2e..2eda015ac 100644 --- a/spec/models/course_team_spec.rb +++ b/spec/models/course_team_spec.rb @@ -65,7 +65,6 @@ def create_student(suffix) CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end @@ -103,7 +102,6 @@ def create_student(suffix) describe 'associations' do it { should belong_to(:course) } - it { should belong_to(:user).optional } it { should have_many(:teams_participants).dependent(:destroy) } it { should have_many(:users).through(:teams_participants) } end diff --git a/spec/models/invitation_spec.rb b/spec/models/invitation_spec.rb deleted file mode 100644 index bc01160a9..000000000 --- a/spec/models/invitation_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Invitation, type: :model do - include ActiveJob::TestHelper - let(:role) {Role.create(name: 'Instructor', parent_id: nil, id: 3, default_page_id: nil)} - let(:student) {Role.create(name: 'Student', parent_id: nil, id: 5, default_page_id: nil)} - let(:instructor) { Instructor.create(name: 'testinstructor', email: 'test@test.com', full_name: 'Test Instructor', password: '123456', role: role) } - let(:user1) { create :user, name: 'rohitgeddam', role: student } - let(:user2) { create :user, name: 'superman', role: student } - let(:invalid_user) { build :user, name: 'INVALID' } - let(:assignment) { create(:assignment, instructor: instructor) } - before(:each) do - ActiveJob::Base.queue_adapter = :test - end - - after(:each) do - clear_enqueued_jobs - end - - - it 'is invitation_factory returning new Invitation' do - invitation = Invitation.invitation_factory(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - expect(invitation).to be_valid - end - - it 'sends an invitation email' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - expect do - invitation.send_invite_email - end.to have_enqueued_job.on_queue('default').exactly(:once) - end - - it 'accepts invitation and change reply_status to Accept(A)' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - invitation.accept_invitation(nil) - expect(invitation.reply_status).to eq(InvitationValidator::ACCEPT_STATUS) - end - - it 'rejects invitation and change reply_status to Reject(R)' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - invitation.decline_invitation(nil) - expect(invitation.reply_status).to eq(InvitationValidator::REJECT_STATUS) - end - - it 'retracts invitation' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - invitation.retract_invitation(nil) - expect { invitation.reload }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'as_json works as expected' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - expect(invitation.as_json).to include('to_user', 'from_user', 'assignment', 'reply_status', 'id') - end - - it 'is invited? false' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - truth = Invitation.invited?(user1.id, user2.id, assignment.id) - expect(truth).to eq(false) - end - - it 'is invited? true' do - invitation = Invitation.create(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - truth = Invitation.invited?(user2.id, user1.id, assignment.id) - expect(truth).to eq(true) - end - - it 'is default reply_status set to WAITING' do - invitation = Invitation.new(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id) - expect(invitation.reply_status).to eq('W') - end - - it 'is valid with valid attributes' do - invitation = Invitation.new(to_id: user1.id, from_id: user2.id, assignment_id: assignment.id, - reply_status: InvitationValidator::WAITING_STATUS) - expect(invitation).to be_valid - end - - it 'is invalid with same from and to attribute' do - invitation = Invitation.new(to_id: user1.id, from_id: user1.id, assignment_id: assignment.id, - reply_status: InvitationValidator::WAITING_STATUS) - expect(invitation).to_not be_valid - end - - it 'is invalid with invalid to user attribute' do - invitation = Invitation.new(to_id: 'INVALID', from_id: user2.id, assignment_id: assignment.id, - reply_status: InvitationValidator::WAITING_STATUS) - expect(invitation).to_not be_valid - end - - it 'is invalid with invalid from user attribute' do - invitation = Invitation.new(to_id: user1.id, from_id: 'INVALID', assignment_id: assignment.id, - reply_status: InvitationValidator::WAITING_STATUS) - expect(invitation).to_not be_valid - end - - it 'is invalid with invalid assignment attribute' do - invitation = Invitation.new(to_id: user1.id, from_id: user2.id, assignment_id: 'INVALID', - reply_status: InvitationValidator::WAITING_STATUS) - expect(invitation).to_not be_valid - end - - it 'is invalid with invalid reply_status attribute' do - invitation = Invitation.new(to_id: user1.id, from_id: user2.id, assignment_id: 'INVALID', - reply_status: 'X') - expect(invitation).to_not be_valid - end -end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index ee5416a3d..5d95b50c3 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -1,154 +1,152 @@ -# frozen_string_literal: true +# # frozen_string_literal: true -require 'rails_helper' +# require 'rails_helper' -RSpec.describe MentoredTeam, type: :model do +# RSpec.describe MentoredTeam, type: :model do - include RolesHelper - # -------------------------------------------------------------------------- - # Global Setup - # -------------------------------------------------------------------------- - # Create the full roles hierarchy once, to be shared by all examples. - let!(:roles) { create_roles_hierarchy } - - # ------------------------------------------------------------------------ - # Helper: DRY-up creation of student users with a predictable pattern. - # ------------------------------------------------------------------------ - def create_student(suffix) - User.create!( - name: suffix, - email: "#{suffix}@example.com", - full_name: suffix.split('_').map(&:capitalize).join(' '), - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) - end - - # ------------------------------------------------------------------------ - # Shared Data Setup: Build core domain objects used across tests. - # ------------------------------------------------------------------------ - let(:institution) do - # All users belong to the same institution to satisfy foreign key constraints. - Institution.create!(name: "NC State") - end - - let(:instructor) do - # The instructor will own assignments and courses in subsequent tests. - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: roles[:instructor].id, - institution_id: institution.id - ) - end - - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) - end - - let!(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - let!(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } - - let(:mentor_role) { create(:role, :mentor) } - - let(:mentor) do - User.create!( - name: "mentor_user", - full_name: "Mentor User", - email: "mentor@example.com", - password_digest: "password", - role_id: mentor_role.id, - institution_id: institution.id - ) - end - - let(:mentored_team) do - MentoredTeam.create!( - parent_id: mentor.id, - assignment: assignment, - name: 'team 3', - user_id: team_owner.id, - mentor: mentor - ) - end - - let(:user) do - User.create!( - name: "student_user", - full_name: "Student User", - email: "student@example.com", - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) - end - - let!(:team) { create(:mentored_team, user: user, assignment: assignment) } - - - describe 'validations' do - it { should validate_presence_of(:mentor) } - it { should validate_presence_of(:type) } +# include RolesHelper +# # -------------------------------------------------------------------------- +# # Global Setup +# # -------------------------------------------------------------------------- +# # Create the full roles hierarchy once, to be shared by all examples. +# let!(:roles) { create_roles_hierarchy } + +# # ------------------------------------------------------------------------ +# # Helper: DRY-up creation of student users with a predictable pattern. +# # ------------------------------------------------------------------------ +# def create_student(suffix) +# User.create!( +# name: suffix, +# email: "#{suffix}@example.com", +# full_name: suffix.split('_').map(&:capitalize).join(' '), +# password_digest: "password", +# role_id: roles[:student].id, +# institution_id: institution.id +# ) +# end + +# # ------------------------------------------------------------------------ +# # Shared Data Setup: Build core domain objects used across tests. +# # ------------------------------------------------------------------------ +# let(:institution) do +# # All users belong to the same institution to satisfy foreign key constraints. +# Institution.create!(name: "NC State") +# end + +# let(:instructor) do +# # The instructor will own assignments and courses in subsequent tests. +# User.create!( +# name: "instructor", +# full_name: "Instructor User", +# email: "instructor@example.com", +# password_digest: "password", +# role_id: roles[:instructor].id, +# institution_id: institution.id +# ) +# end + +# let(:team_owner) do +# User.create!( +# name: "team_owner", +# full_name: "Team Owner", +# email: "team_owner@example.com", +# password_digest: "password", +# role_id: roles[:student].id, +# institution_id: institution.id +# ) +# end + +# let!(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } +# let!(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } + +# let(:mentor_role) { create(:role, :mentor) } + +# let(:mentor) do +# User.create!( +# name: "mentor_user", +# full_name: "Mentor User", +# email: "mentor@example.com", +# password_digest: "password", +# role_id: mentor_role.id, +# institution_id: institution.id +# ) +# end + +# let(:mentored_team) do +# MentoredTeam.create!( +# parent_id: mentor.id, +# assignment: assignment, +# name: 'team 3', +# ) +# end + +# let(:user) do +# User.create!( +# name: "student_user", +# full_name: "Student User", +# email: "student@example.com", +# password_digest: "password", +# role_id: roles[:student].id, +# institution_id: institution.id +# ) +# end + +# let!(:team) { create(:mentored_team, assignment: assignment) } + + +# describe 'validations' do +# it { should validate_presence_of(:mentor) } +# it { should validate_presence_of(:type) } - it 'requires type to be MentoredTeam' do - team.type = 'AssignmentTeam' - expect(team).not_to be_valid - expect(team.errors[:type]).to include('must be MentoredTeam') - end - - it 'requires mentor to have mentor role' do - non_mentor = create(:user) - team.mentor = non_mentor - expect(team).not_to be_valid - expect(team.errors[:mentor]).to include('must have mentor role') - end - end - - describe 'associations' do - it { should belong_to(:mentor).class_name('User') } - it { should belong_to(:assignment) } - it { should belong_to(:user).optional } - it { should have_many(:teams_participants).dependent(:destroy) } - it { should have_many(:users).through(:teams_participants) } - end - - describe 'team management' do - let(:enrolled_user) { create(:user) } - - before do - @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) - end - - it 'can add enrolled user' do - expect(team.add_member(enrolled_user)).to be_truthy - expect(team.has_member?(enrolled_user)).to be_truthy - end - - it 'cannot add mentor as member' do - expect(team.add_member(team.mentor)).to be_falsey - expect(team.has_member?(team.mentor)).to be_falsey - end - - it 'can assign new mentor' do - new_mentor = create(:user, role: mentor_role) - expect(team.assign_mentor(new_mentor)).to be_truthy - expect(team.mentor).to eq(new_mentor) - end - - it 'cannot assign non-mentor as mentor' do - non_mentor = create(:user) - expect(team.assign_mentor(non_mentor)).to be_falsey - expect(team.mentor).not_to eq(non_mentor) - end - end -end +# it 'requires type to be MentoredTeam' do +# team.type = 'AssignmentTeam' +# expect(team).not_to be_valid +# expect(team.errors[:type]).to include('must be MentoredTeam') +# end + +# it 'requires mentor to have mentor role' do +# non_mentor = create(:user) +# team.mentor = non_mentor +# expect(team).not_to be_valid +# expect(team.errors[:mentor]).to include('must have mentor role') +# end +# end + +# describe 'associations' do +# it { should belong_to(:mentor).class_name('User') } +# it { should belong_to(:assignment) } +# it { should belong_to(:user).optional } +# it { should have_many(:teams_participants).dependent(:destroy) } +# it { should have_many(:users).through(:teams_participants) } +# end + +# describe 'team management' do +# let(:enrolled_user) { create(:user) } + +# before do +# @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) +# end + +# it 'can add enrolled user' do +# expect(team.add_member(enrolled_user)).to be_truthy +# expect(team.has_member?(enrolled_user)).to be_truthy +# end + +# it 'cannot add mentor as member' do +# expect(team.add_member(team.mentor)).to be_falsey +# expect(team.has_member?(team.mentor)).to be_falsey +# end + +# it 'can assign new mentor' do +# new_mentor = create(:user, role: mentor_role) +# expect(team.assign_mentor(new_mentor)).to be_truthy +# expect(team.mentor).to eq(new_mentor) +# end + +# it 'cannot assign non-mentor as mentor' do +# non_mentor = create(:user) +# expect(team.assign_mentor(non_mentor)).to be_falsey +# expect(team.mentor).not_to eq(non_mentor) +# end +# end +# end diff --git a/spec/models/questionnaire_spec.rb b/spec/models/questionnaire_spec.rb index 837dc818a..1af1c1ef8 100644 --- a/spec/models/questionnaire_spec.rb +++ b/spec/models/questionnaire_spec.rb @@ -122,7 +122,7 @@ questionnaire.save! question1.save! question2.save! - copied_questionnaire = Questionnaire.copy_questionnaire_details( { id: questionnaire.id}) + copied_questionnaire = Questionnaire.copy_questionnaire_details( { id: questionnaire.id, instructor_id: instructor.id}) expect(copied_questionnaire.instructor_id).to eq(questionnaire.instructor_id) expect(copied_questionnaire.name).to eq("Copy of #{questionnaire.name}") expect(copied_questionnaire.created_at).to be_within(1.second).of(Time.zone.now) @@ -134,7 +134,7 @@ questionnaire.save! question1.save! question2.save! - copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id }) + copied_questionnaire = described_class.copy_questionnaire_details({ id: questionnaire.id, instructor_id: instructor.id }) expect(copied_questionnaire.items.count).to eq(2) expect(copied_questionnaire.items.first.txt).to eq(question1.txt) expect(copied_questionnaire.items.second.txt).to eq(question2.txt) diff --git a/spec/models/response_spec.rb b/spec/models/response_spec.rb index b4d8e4707..976128828 100644 --- a/spec/models/response_spec.rb +++ b/spec/models/response_spec.rb @@ -4,16 +4,19 @@ describe Response do - let(:user) { User.new(id: 1, role_id: 1, name: 'no name', full_name: 'no one') } - let(:team) {Team.new} - let(:participant) { Participant.new(id: 1, user: user) } - let(:assignment) { Assignment.new(id: 1, name: 'Test Assignment') } - let(:answer) { Answer.new(answer: 1, comments: 'Answer text', question_id: 1) } - let(:item) { ScoredItem.new(id: 1, weight: 2) } - let(:questionnaire) { Questionnaire.new(id: 1, items: [item], max_question_score: 5) } - let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team) } - let(:response_map) { ResponseMap.new(assignment: assignment, reviewee: participant, reviewer: participant) } - let(:response) { Response.new(map_id: 1, response_map: review_response_map, scores: [answer]) } + let(:user) { create(:user, :student) } + let(:user2) { create(:user, :student) } + let(:assignment) { create(:assignment, name: 'Test Assignment') } + let(:team) {create(:team, :with_assignment, name: 'Test Team')} + let(:participant) { AssignmentParticipant.create!(assignment: assignment, user: user, handle: user.name) } + let(:participant2) { AssignmentParticipant.create!(assignment: assignment, user: user2, handle: user2.name) } + let(:item) { ScoredItem.new(weight: 2) } + let(:answer) { Answer.new(answer: 1, comments: 'Answer text', item:item) } + let(:questionnaire) { Questionnaire.new(items: [item], min_question_score: 0, max_question_score: 5) } + let(:assignment_questionnaire) { AssignmentQuestionnaire.create!(assignment: assignment, questionnaire: questionnaire, used_in_round: 1, notification_limit: 5.0)} + let(:review_response_map) { ReviewResponseMap.new(assignment: assignment, reviewee: team, reviewer: participant2) } + let(:response_map) { ResponseMap.new(assignment: assignment, reviewee: participant, reviewer: participant2) } + let(:response) { Response.new(map_id: review_response_map.id, response_map: review_response_map, round:1, scores: [answer]) } # Compare the current response score with other scores on the same artifact, and test if the difference is significant enough to notify # instructor. @@ -34,7 +37,7 @@ allow(response).to receive(:aggregate_questionnaire_score).and_return(93) allow(response).to receive(:maximum_score).and_return(100) allow(response).to receive(:questionnaire_by_answer).with(answer).and_return(questionnaire) - allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: 1, questionnaire_id: 1) + allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: assignment.id, questionnaire_id: questionnaire.id) .and_return(double('AssignmentQuestionnaire', notification_limit: 5.0)) expect(response.reportable_difference?).to be true end @@ -43,14 +46,9 @@ end # Calculate the total score of a review - describe '#calculate_total_score' do + describe '#aggregate_questionnaire_score' do it 'computes the total score of a review' do - question2 = double('ScoredItem', weight: 2) - arr_question2 = [question2] - allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) - allow(question2).to receive(:scorable?).and_return(true) - allow(question2).to receive(:answer).and_return(answer) - expect(response.calculate_total_score).to eq(2) + expect(response.aggregate_questionnaire_score).to eq(2) end end @@ -75,42 +73,45 @@ # Returns the maximum possible score for this response - only count the scorable questions, only when the answer is not nil (we accept nil as # answer for scorable questions, and they will not be counted towards the total score) describe '#maximum_score' do - it 'returns the maximum possible score for current response' do - question2 = double('ScoredItem', weight: 2) - arr_question2 = [question2] - allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) - allow(question2).to receive(:scorable?).and_return(true) - allow(response).to receive(:questionnaire_by_answer).with(answer).and_return(questionnaire) - allow(questionnaire).to receive(:max_question_score).and_return(5) - expect(response.maximum_score).to eq(10) + before do + allow(response.response_assignment) + .to receive_message_chain(:assignment_questionnaires, :find_by) + .with(used_in_round: 1) + .and_return(assignment_questionnaire) end + context 'when answers are present and scorable' do + it 'returns weight * max_question_score' do + # item.weight = 2, max_question_score = 5 → 10 + expect(response.maximum_score).to eq(10) + end + end + + context 'when answer is nil' do + before { answer.answer = nil } - it 'returns the maximum possible score for current response without score' do - response.scores = [] - allow(response).to receive(:questionnaire_by_answer).with(nil).and_return(questionnaire) - allow(questionnaire).to receive(:max_question_score).and_return(5) - expect(response.maximum_score).to eq(0) + it 'does not count that answer' do + expect(response.maximum_score).to eq(0) + end end - # Expects to return the participant's assignment for a ResponseMap object - it 'returns the appropriate assignment for ResponseMap' do - allow(Participant).to receive(:find).and_return(participant) - allow(participant).to receive(:assignment).and_return(assignment) + context 'when there are no scores' do + before { response.scores = [] } - expect(response_map.response_assignment).to eq(assignment) + it 'returns 0' do + # allow(AssignmentQuestionnaire).to receive(:find_by).with(assignment_id: assignment.id, questionnaire_id: questionnaire.id) + # .and_return(double('AssignmentQuestionnaire', notification_limit: 5.0)) + expect(response.maximum_score).to eq(0) + end end + end - # Expects to return ResponseMap's assignment - it 'returns the appropriate assignment for ReviewResponseMap' do - question2 = double('ScoredItem', weight: 2) - arr_question2 = [question2] - allow(Item).to receive(:find_with_order).with([1]).and_return(arr_question2) - allow(question2).to receive(:scorable?).and_return(true) - allow(questionnaire).to receive(:max_question_score).and_return(5) - allow(review_response_map).to receive(:assignment).and_return(assignment) + describe '#response_assignment' do + it 'returns assignment for ResponseMap' do + expect(response_map.response_assignment).to eq(assignment) + end + it 'returns assignment for ReviewResponseMap' do expect(review_response_map.response_assignment).to eq(assignment) - end end end diff --git a/spec/models/ta_mapping_spec.rb b/spec/models/ta_mapping_spec.rb index 21d08020b..e2d4e062c 100644 --- a/spec/models/ta_mapping_spec.rb +++ b/spec/models/ta_mapping_spec.rb @@ -1,7 +1,22 @@ # frozen_string_literal: true require 'rails_helper' +require 'json_web_token' RSpec.describe TaMapping, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + + let(:ta) {create(:user, :ta)} + let(:course) {create(:course)} + + let(:ta_token) { JsonWebToken.encode({id: ta.id}) } + + describe 'Teaching Assistant access' do + before do + TaMapping.create!(course_id: course.id, user_id: ta.id) + end + + it 'creates the TA mapping' do + expect(TaMapping.exists?(course_id: course.id, user_id: ta.id)).to be true + end + end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 0106cb971..fe55ac3c1 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -77,7 +77,6 @@ def create_student(suffix) AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end @@ -85,7 +84,6 @@ def create_student(suffix) CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9d528540b..4e51c9933 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,16 +5,16 @@ ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' # Prevent database truncation if the environment is production -abort("The Rails environment is running in production mode!") if Rails.env.production? +abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' require 'factory_bot_rails' require 'database_cleaner/active_record' # Override DATABASE_URL for tests to prevent remote DB errors -#if Rails.env.test? -# ENV['DATABASE_URL'] = 'mysql2://root:expertiza@127.0.0.1/reimplementation_test' -#end +if Rails.env.test? + ENV['DATABASE_URL'] = 'mysql2://root:expertiza@127.0.0.1/reimplementation_test' +end RSpec.configure do |config| config.include FactoryBot::Syntax::Methods @@ -77,12 +77,13 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + # config.fixture_path = Rails.root.join('spec/fixtures') + + # Since we're using Factory Bot instead of fixtures, we don't need fixture_path + # config.fixture_path is deprecated in newer RSpec versions anyway - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. - config.use_transactional_fixtures = true + # We're using DatabaseCleaner instead of transactional fixtures + # config.use_transactional_fixtures = false # You can uncomment this line to turn off ActiveRecord support entirely. # config.use_active_record = false diff --git a/spec/requests/api/v1/grades_controller_spec.rb b/spec/requests/api/v1/grades_controller_spec.rb new file mode 100644 index 000000000..b728b9f5f --- /dev/null +++ b/spec/requests/api/v1/grades_controller_spec.rb @@ -0,0 +1,430 @@ +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Grades API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:instructor) do + User.create!( + name: "instructor", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Name", + email: "instructor@example.com" + ) + end + + let(:ta) do + User.create!( + name: "ta", + password_digest: "password", + role_id: @roles[:ta].id, + full_name: "Teaching Assistant", + email: "ta@example.com" + ) + end + + let(:student) do + User.create!( + name: "student", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Name", + email: "student@example.com" + ) + end + + let(:student2) do + User.create!( + name: "student2", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Two", + email: "student2@example.com" + ) + end + + let(:course) {create(:course)} + + let!(:assignment) { Assignment.create!(name: 'Test Assignment', instructor_id: instructor.id, course_id: course.id) } + let!(:team) { AssignmentTeam.create!(name: 'Team 1', parent_id: assignment.id) } + let!(:participant) { AssignmentParticipant.create!(user_id: student.id, parent_id: assignment.id, team_id: team.id, handle: student.name) } + let!(:participant2) { AssignmentParticipant.create!(user_id: student2.id, parent_id: assignment.id, team_id: team.id, handle: student2.name) } + + + let(:instructor_token) { JsonWebToken.encode({id: instructor.id}) } + let(:ta_token) { JsonWebToken.encode({id: ta.id}) } + let(:student_token) { JsonWebToken.encode({id: student.id}) } + + let(:Authorization) { "Bearer #{instructor_token}" } + + path '/grades/{assignment_id}/view_all_scores' do + get 'Retrieve all review scores for an assignment' do + tags 'Grades' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :assignment_id, in: :path, type: :integer, description: 'ID of the assignment' + parameter name: :participant_id, in: :query, type: :integer, required: false, description: 'ID of the participant' + parameter name: :team_id, in: :query, type: :integer, required: false, description: 'ID of the team' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns all scores for assignment' do + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('team_scores') + expect(data).to have_key('participant_scores') + end + end + + response '200', 'Returns participant scores when participant_id provided' do + let(:assignment_id) { assignment.id } + let(:participant_id) { participant.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['participant_scores']).to be_present + end + end + + response '200', 'Returns team scores when team_id provided' do + let(:assignment_id) { assignment.id } + let(:team_id) { team.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['team_scores']).to be_present + end + end + + response '403', 'Forbidden - Student cannot access' do + let(:assignment_id) { assignment.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to view_all_scores this grades') + end + end + + response '401', 'Unauthorized' do + let(:assignment_id) { assignment.id } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + path '/grades/{assignment_id}/view_our_scores' do + get 'Retrieve team scores for the requesting student' do + tags 'Grades' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :assignment_id, in: :path, type: :integer, description: 'ID of the assignment' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns team scores' do + let(:assignment_id) { assignment.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('reviews_of_our_work') + expect(data).to have_key('avg_score_of_our_work') + end + end + + response '403', 'Assignment Participant not found' do + let(:assignment_id) { 999 } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to view_our_scores this grades') + end + end + + response '401', 'Unauthorized' do + let(:assignment_id) { assignment.id } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + path '/grades/{assignment_id}/view_my_scores' do + get 'Retrieve individual participant scores' do + tags 'Grades' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :assignment_id, in: :path, type: :integer, description: 'ID of the assignment' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns participant scores' do + let(:assignment_id) { assignment.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('reviews_of_me') + expect(data).to have_key('reviews_by_me') + expect(data).to have_key('author_feedback_scores') + expect(data).to have_key('avg_score_from_my_teammates') + expect(data).to have_key('avg_score_to_my_teammates') + expect(data).to have_key('avg_score_from_my_authors') + end + end + + response '403', 'Participant not found' do + let(:assignment_id) { 999 } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to view_my_scores this grades') + end + end + + response '401', 'Unauthorized' do + let(:assignment_id) { assignment.id } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + path '/grades/{participant_id}/edit' do + get 'Get grade assignment interface data' do + tags 'Grades' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :participant_id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns participant, assignment, items, and scores' do + let(:participant_id) { participant.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('participant') + expect(data).to have_key('assignment') + expect(data).to have_key('items') + expect(data).to have_key('scores') + expect(data['scores']).to have_key('my_team') + expect(data['scores']).to have_key('my_own') + end + end + + response '404', 'Participant not found' do + let(:participant_id) { 999 } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Found') + end + end + + response '403', 'Forbidden - Student cannot access' do + let(:participant_id) { participant.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to edit this grades') + end + end + + response '401', 'Unauthorized' do + let(:participant_id) { participant.id } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + path '/grades/{participant_id}/assign_grade' do + patch 'Assign grades and comment to team' do + tags 'Grades' + consumes 'application/json' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :participant_id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :grade_data, in: :body, schema: { + type: :object, + properties: { + grade_for_submission: { type: :number, description: 'Grade for the submission' }, + comment_for_submission: { type: :string, description: 'Comment for the submission' } + } + } + + response '200', 'Team grade and comment assigned successfully' do + let(:participant_id) { participant.id } + let(:grade_data) { { grade_for_submission: 95, comment_for_submission: 'Excellent work!' } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['message']).to eq("Grade and comment assigned to team #{team.name} successfully.") + + team.reload + expect(team.grade_for_submission).to eq(95) + expect(team.comment_for_submission).to eq('Excellent work!') + end + end + + response '422', 'Failed to assign team grade or comment' do + let(:participant_id) { participant.id } + let(:grade_data) { { grade_for_submission: nil } } + + before do + allow_any_instance_of(AssignmentTeam).to receive(:save).and_return(false) + end + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq("Failed to assign grade or comment to team #{team.name}." ) + end + end + + response '404', 'Participant not found' do + let(:participant_id) { 999 } + let(:grade_data) { { grade_for_submission: 95 } } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Found') + end + end + + response '403', 'Forbidden - Student cannot access' do + let(:participant_id) { participant.id } + let(:grade_data) { { grade_for_submission: 95 } } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to assign_grade this grades') + end + end + + response '401', 'Unauthorized' do + let(:participant_id) { participant.id } + let(:grade_data) { { grade_for_submission: 95 } } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + path '/grades/{participant_id}/instructor_review' do + get 'Begin or continue grading a submission' do + tags 'Grades' + produces 'application/json' + security [bearer_auth: []] + + parameter name: :participant_id, in: :path, type: :integer, description: 'ID of the participant' + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'Returns mapping and redirect information for new review' do + let(:participant_id) { participant.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to have_key('map_id') + expect(data).to have_key('response_id') + expect(data).to have_key('redirect_to') + expect(data['redirect_to']).to include('/response/new/') if data['response_id'].nil? + end + end + + response '200', 'Returns mapping and redirect information for existing review' do + let(:participant_id) { participant.id } + + before do + reviewer = AssignmentParticipant.create!(user_id: instructor.id, parent_id: assignment.id, handle: instructor.name) + mapping = ReviewResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: team.id + ) + Response.create!(map_id: mapping.id) + end + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['response_id']).to be_present + expect(data['redirect_to']).to include('/response/edit/') + end + end + + response '404', 'Participant not found' do + let(:participant_id) { 999 } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Found') + end + end + + response '403', 'Forbidden - Student cannot access' do + let(:participant_id) { participant.id } + let(:Authorization) { "Bearer #{student_token}" } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('You are not authorized to instructor_review this grades') + end + end + + response '401', 'Unauthorized' do + let(:participant_id) { participant.id } + let(:Authorization) { 'Bearer invalid_token' } + + run_test! do |response| + expect(JSON.parse(response.body)['error']).to eq('Not Authorized') + end + end + end + end + + # Testing with Teaching Assistant permissions + describe 'Teaching Assistant access' do + before do + TaMapping.create!(course_id: course.id, user_id: ta.id) + end + + it 'creates the TA mapping' do + expect(TaMapping.exists?(course_id: course.id, user_id: ta.id)).to be true + end + + it 'allows TA to access view_all_scores' do + get "/grades/#{assignment.id}/view_all_scores", headers: { 'Authorization' => "Bearer #{ta_token}" } + expect(response).to have_http_status(:ok) + end + + it 'denies TA from accessing instructor_review' do + get "/grades/#{participant.id}/instructor_review", headers: { 'Authorization' => "Bearer #{ta_token}" } + expect(response).to have_http_status(:forbidden) + end + + it 'denies TA from assigning grades' do + patch "/grades/#{participant.id}/assign_grade", + params: { grade_for_submission: 90 }, + headers: { 'Authorization' => "Bearer #{ta_token}" } + expect(response).to have_http_status(:forbidden) + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/invitation_controller_spec.rb b/spec/requests/api/v1/invitation_controller_spec.rb deleted file mode 100644 index 514a57c47..000000000 --- a/spec/requests/api/v1/invitation_controller_spec.rb +++ /dev/null @@ -1,322 +0,0 @@ -# frozen_string_literal: true - -require 'swagger_helper' -require 'rails_helper' -require 'json_web_token' - -RSpec.describe 'Invitations API', type: :request do - before(:all) do - @roles = create_roles_hierarchy - end - - let(:student) { - User.create( - name: "studenta", - password_digest: "password", - role_id: @roles[:student].id, - full_name: "student A", - email: "testuser@example.com", - mru_directory_path: "/home/testuser", - ) - } - - let(:prof) { - User.create( - name: "profa", - password_digest: "password", - role_id: @roles[:instructor].id, - full_name: "Prof A", - email: "testuser@example.com", - mru_directory_path: "/home/testuser", - ) - } - - let(:token) { JsonWebToken.encode({id: student.id}) } - let(:Authorization) { "Bearer #{token}" } - let(:user1) { create :user, name: 'rohitgeddam', role_id: @roles[:student].id } - let(:user2) { create :user, name: 'superman', role_id: @roles[:student].id } - let(:invalid_user) { build :user, name: 'INVALID', role_id: nil } - let(:assignment) { Assignment.create!(id: 1, name: 'Test Assignment', instructor_id: prof.id) } - let(:invitation) { Invitation.create!(from_user: user1, to_user: user2, assignment: assignment) } - - path '/invitations' do - - get('list invitations') do - tags 'Invitations' - produces 'application/json' - response(200, 'Success') do - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - end - - post('create invitation') do - tags 'Invitations' - consumes 'application/json' - parameter name: :invitation, in: :body, schema: { - type: :object, - properties: { - assignment_id: { type: :integer }, - from_id: { type: :integer }, - to_id: { type: :integer }, - reply_status: { type: :string } - }, - required: %w[assignment_id from_id to_id] - } - - response(201, 'Create successful') do - let(:invitation) { { to_id: user1.id, from_id: user2.id, assignment_id: assignment.id } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:invitation) { { to_id: invalid_user.id, from_id: user2.id, assignment_id: assignment.id } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:invitation) { { to_id: user1.id, from_id: invalid_user.id, assignment_id: assignment.id } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:invitation) { { to_id: user1.id, from_id: user2.id, assignment_id: nil } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:invitation) { { to_id: user1.id, from_id: user2.id, assignment_id: assignment.id, reply_status: 'I' } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:invitation) { { to_id: user1.id, from_id: user1.id, assignment_id: assignment.id } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - end - end - - path '/invitations/{id}' do - parameter name: 'id', in: :path, type: :integer, description: 'id of the invitation' - get('show invitation') do - tags 'Invitations' - response(200, 'Show invitation') do - let(:id) { invitation.id } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(404, 'Not found') do - let(:id) { 'INVALID' } - - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - end - - patch('update invitation') do - tags 'Invitations' - consumes 'application/json' - parameter name: :invitation_status, in: :body, schema: { - type: :object, - properties: { - reply_status: { type: :string } - }, - required: %w[] - } - - response(200, 'Update successful') do - let(:id) { invitation.id } - let(:invitation_status) { { reply_status: InvitationValidator::ACCEPT_STATUS } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(200, 'Update successful') do - let(:id) { invitation.id } - let(:invitation_status) { { reply_status: InvitationValidator::REJECT_STATUS } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(422, 'Invalid request') do - let(:id) { invitation.id } - let(:invitation_status) { { reply_status: 'Z' } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(404, 'Not found') do - let(:id) { invitation.id + 10 } - let(:invitation_status) { { reply_status: 'A' } } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - delete('Delete invitation') do - tags 'Invitations' - response(204, 'Delete successful') do - let(:id) { invitation.id } - run_test! - end - - response(404, 'Not found') do - let(:id) { invitation.id + 100 } - - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - end - end - end - - path '/invitations/user/{user_id}/assignment/{assignment_id}' do - parameter name: 'user_id', in: :path, type: :integer, description: 'id of user' - parameter name: 'assignment_id', in: :path, type: :integer, description: 'id of assignment' - get('Show all invitation for the given user and assignment') do - tags 'Invitations' - response(200, 'Show all invitations for the user for an assignment') do - let(:user_id) { user1.id } - let(:assignment_id) { assignment.id } - - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(404, 'Not found') do - let(:user_id) { 'INVALID' } - let(:assignment_id) { assignment.id } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(404, 'Not found') do - let(:user_id) { user1.id } - let(:assignment_id) { 'INVALID' } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - - response(404, 'Not found') do - let(:user_id) { 'INVALID' } - let(:assignment_id) { 'INVALID' } - after do |example| - example.metadata[:response][:content] = { - 'application/json' => { - example: JSON.parse(response.body, symbolize_names: true) - } - } - end - run_test! - end - end - end -end diff --git a/spec/requests/api/v1/questionnaires_spec.rb b/spec/requests/api/v1/questionnaires_controller_spec.rb similarity index 93% rename from spec/requests/api/v1/questionnaires_spec.rb rename to spec/requests/api/v1/questionnaires_controller_spec.rb index 3950d7831..046efd83e 100644 --- a/spec/requests/api/v1/questionnaires_spec.rb +++ b/spec/requests/api/v1/questionnaires_controller_spec.rb @@ -11,7 +11,7 @@ end let(:prof) { - User.create( + User.create!( name: "profa", password_digest: "password", role_id: @roles[:instructor].id, @@ -332,7 +332,22 @@ path '/questionnaires/copy/{id}' do parameter name: 'id', in: :path, type: :integer - let(:valid_questionnaire_params) do + parameter name: :questionnaire1, in: :body, schema: { + type: :object, + properties: { + name: { type: :string }, + questionnaire_type: { type: :string }, + private: { type: :boolean }, + min_question_score: { type: :integer }, + max_question_score: { type: :integer }, + instructor_id: { type: :integer } + }, + required: ['name', 'questionnaire_type', 'instructor_id'] + } + + before {prof} + + let!(:valid_questionnaire_params) do { name: 'Test Questionnaire', questionnaire_type: 'AuthorFeedbackReview', @@ -340,19 +355,17 @@ min_question_score: 0, max_question_score: 5, instructor_id: prof.id - } + } end - let(:questionnaire) do - prof - Questionnaire.create(valid_questionnaire_params) + let!(:questionnaire1) do + Questionnaire.create!(valid_questionnaire_params) end - - let(:id) do - questionnaire - questionnaire.id + + let!(:id) do + questionnaire1.id end - + post('copy questionnaire') do tags 'Questionnaires' consumes 'application/json' @@ -360,6 +373,7 @@ # post request on /questionnaires/copy/{id} returns 200 successful response when request returns copied questionnaire with questionnaire id is present in the database response(200, 'successful') do + let(:questionnaire) { valid_questionnaire_params } run_test! do expect(response.body).to include('"name":"Copy of Test Questionnaire"') end @@ -374,4 +388,4 @@ end end end -end +end \ No newline at end of file diff --git a/spec/requests/api/v1/teams_spec.rb b/spec/requests/api/v1/teams_controller_spec.rb similarity index 99% rename from spec/requests/api/v1/teams_spec.rb rename to spec/requests/api/v1/teams_controller_spec.rb index cf42dc697..9c4e660d9 100644 --- a/spec/requests/api/v1/teams_spec.rb +++ b/spec/requests/api/v1/teams_controller_spec.rb @@ -81,7 +81,6 @@ CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end @@ -89,7 +88,6 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end diff --git a/spec/requests/api/v1/teams_participants_controller_spec.rb b/spec/requests/api/v1/teams_participants_controller_spec.rb index f16bd5599..2b973f3a3 100644 --- a/spec/requests/api/v1/teams_participants_controller_spec.rb +++ b/spec/requests/api/v1/teams_participants_controller_spec.rb @@ -91,14 +91,12 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end let(:team_with_course) do CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end @@ -356,7 +354,6 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end let(:assignment_participant1) { AssignmentParticipant.create!(parent_id: assignment.id, user: student_user, handle: student_user.name) } @@ -369,7 +366,6 @@ CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end let(:course_participant1) { CourseParticipant.create!(parent_id: course.id, user: student_user, handle: student_user.name) } diff --git a/users.ibd b/users.ibd new file mode 100644 index 000000000..72b71ae28 Binary files /dev/null and b/users.ibd differ