-
Notifications
You must be signed in to change notification settings - Fork 194
E2606 Final Project Backend PR #344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9b0ab9f
d40fc01
2c841b9
d851de8
e337816
dd8b30f
72e49e6
85de9a4
3876a11
371ca19
0dc32fd
2643b2c
afaf5cf
3d57637
65ecaec
3c63a99
d13e69d
3430dc1
5c5dd87
58c46d0
77e40a5
c47323c
a4c8de0
2ff1fe0
298297f
8a46c16
7f88a13
e3e6898
eaa6d11
e4920bf
09551a1
b04b81d
6117c20
cf24ad7
9a1cee4
b847f8e
6168155
0a06bda
ce62daa
d787275
ba5f298
7e1e407
dbc6d21
9c86eda
37b002b
875fc04
cc02b8f
32e4c32
bdacc5b
91e42d5
1d20ad6
5349c55
b873400
ce98359
50ec75a
ab2155f
e9d0eca
f26517d
6098b2d
732ee95
a3d069c
cc6375c
d623e97
c6c30fd
7ae0d92
829ecee
7af8cb3
39e1379
e70b584
1c4b6be
f397e9a
7066256
de1aaa5
0d9e750
f498be0
87fefc2
6616b66
7de3289
4d45dc6
22188bd
50f8eba
1ff3e32
a8f3b83
0037058
344bd84
750c4fd
916ba4f
d682d0b
a996dde
4e79f2b
80f28ce
96ca219
4c6a4c0
2d0d45a
21596c4
9498cd0
cb40fbf
0b84668
8b48da3
c418263
df12709
efccbdf
0fde405
b453c28
9da1239
543c8d2
a040e24
5921a2d
1c448bf
512199e
c05523a
a2329ed
dfa0a88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| # This controller handles exporting data from the application to various formats. | ||
| class ExportController < ApplicationController | ||
| SUPPORTED_EXPORT_CLASSES = { | ||
| "User" => User, | ||
| "Team" => Team, | ||
| "CourseParticipant" => CourseParticipant, | ||
| "AssignmentParticipant" => AssignmentParticipant, | ||
| "ProjectTopic" => ProjectTopic, | ||
| "Questionnaire" => Questionnaire, | ||
| "Item" => Item, | ||
| "QuestionAdvice" => QuestionAdvice | ||
| }.freeze | ||
|
|
||
| before_action :export_params | ||
|
|
||
| def resolve_export_class(name) | ||
| SUPPORTED_EXPORT_CLASSES[name.to_s] | ||
| end | ||
|
|
||
| def index | ||
| klass = resolve_export_class(params[:class]) | ||
| raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? | ||
|
|
||
| render json: export_metadata_for(klass), status: :ok | ||
| rescue StandardError => e | ||
| render json: { error: e.message }, status: :unprocessable_entity | ||
| end | ||
|
Comment on lines
+25
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raw exception messages are returned to clients. These rescues expose internal error details via Also applies to: 71-73 🤖 Prompt for AI Agents |
||
|
|
||
| def export | ||
| # Parse ordered fields from JSON, if provided | ||
| ordered_fields = | ||
| begin | ||
| JSON.parse(params[:ordered_fields]) if params[:ordered_fields] | ||
| rescue JSON::ParserError | ||
| render json: { error: "Invalid JSON for ordered_fields" }, status: :unprocessable_entity | ||
| return | ||
| end | ||
|
Comment on lines
+31
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Line 33 accepts any JSON value. Non-array input (object/string/number) can flow into export mapping and produce invalid headers/rows or runtime errors. Suggested fix ordered_fields =
begin
JSON.parse(params[:ordered_fields]) if params[:ordered_fields]
rescue JSON::ParserError
render json: { error: "Invalid JSON for ordered_fields" }, status: :unprocessable_entity
return
end
+
+ if ordered_fields && (!ordered_fields.is_a?(Array) || ordered_fields.any? { |f| !f.is_a?(String) })
+ render json: { error: "ordered_fields must be a JSON array of strings" }, status: :unprocessable_entity
+ return
+ end🤖 Prompt for AI Agents |
||
|
|
||
| klass = resolve_export_class(params[:class]) | ||
| raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? | ||
|
|
||
| csv_file = if klass == Team | ||
| Team.with_assignment_context(params[:assignment_id]) do | ||
| Export.perform(klass, ordered_fields) | ||
| end | ||
| elsif klass == AssignmentParticipant | ||
| # AssignmentParticipant export should include only the | ||
| # participants for the selected assignment. | ||
| AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do | ||
| named_export_files(Export.perform(klass, ordered_fields), assignment_participant_export_name(params[:assignment_id])) | ||
| end | ||
| elsif klass == CourseParticipant | ||
| # CourseParticipant export should include only the | ||
| # participants for the selected course. | ||
| CourseParticipant.with_course_context(params[:course_id], current_user) do | ||
| named_export_files(Export.perform(klass, ordered_fields), course_participant_export_name(params[:course_id])) | ||
| end | ||
| elsif klass == ProjectTopic | ||
| ProjectTopic.with_assignment_context(params[:assignment_id]) do | ||
| Export.perform(klass, ordered_fields) | ||
| end | ||
| else | ||
| Export.perform(klass, ordered_fields) | ||
| end | ||
|
|
||
| render json: { | ||
| message: "#{params[:class]} has been exported!", | ||
| file: csv_file | ||
| }, status: :ok | ||
|
|
||
| rescue StandardError => e | ||
| render json: { error: e.message }, status: :unprocessable_entity | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def export_params | ||
| params.permit(:class, :ordered_fields, :assignment_id, :course_id) | ||
| end | ||
|
|
||
| def export_metadata_for(klass) | ||
| # The participant CSV intentionally exposes username only. Other user | ||
| # details are previewed from the users table but not exported as input. | ||
| if klass == AssignmentParticipant | ||
| AssignmentParticipant.with_assignment_context(params[:assignment_id], current_user) do | ||
| return { | ||
| mandatory_fields: klass.mandatory_fields, | ||
| optional_fields: klass.optional_fields, | ||
| external_fields: klass.external_fields | ||
| } | ||
| end | ||
| end | ||
|
|
||
| if klass == CourseParticipant | ||
| CourseParticipant.with_course_context(params[:course_id], current_user) do | ||
| return { | ||
| mandatory_fields: klass.mandatory_fields, | ||
| optional_fields: klass.optional_fields, | ||
| external_fields: klass.external_fields | ||
| } | ||
| end | ||
| end | ||
|
|
||
| if klass == Team | ||
| Team.with_assignment_context(params[:assignment_id]) do | ||
| return { | ||
| mandatory_fields: klass.mandatory_fields, | ||
| optional_fields: klass.optional_fields, | ||
| external_fields: klass.external_fields | ||
| } | ||
| end | ||
| end | ||
|
|
||
| if klass == ProjectTopic | ||
| ProjectTopic.with_assignment_context(params[:assignment_id]) do | ||
| return { | ||
| mandatory_fields: klass.mandatory_fields, | ||
| optional_fields: klass.optional_fields, | ||
| external_fields: klass.external_fields | ||
| } | ||
| end | ||
| end | ||
|
|
||
| { | ||
| mandatory_fields: klass.mandatory_fields, | ||
| optional_fields: klass.optional_fields, | ||
| external_fields: klass.external_fields | ||
| } | ||
| end | ||
|
|
||
| # Scoped participant exports use readable filenames while keeping the CSV | ||
| # body limited to import-friendly fields such as username. | ||
| def named_export_files(files, name) | ||
| Array(files).map { |file| file.merge(name: name) } | ||
| end | ||
|
|
||
| def assignment_participant_export_name(assignment_id) | ||
| assignment = Assignment.find_by(id: assignment_id) | ||
| scoped_export_name('AssignmentParticipant', assignment&.name, assignment_id) | ||
| end | ||
|
|
||
| def course_participant_export_name(course_id) | ||
| course = Course.find_by(id: course_id) | ||
| scoped_export_name('CourseParticipant', course&.name, course_id) | ||
| end | ||
|
|
||
| def scoped_export_name(base_name, scope_name, scope_id) | ||
| parts = [base_name, scope_name.presence || 'scope', scope_id.presence].compact | ||
| parts.join('_').parameterize(separator: '_') | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,17 @@ | ||
| require 'csv' | ||
|
|
||
| class GradesController < ApplicationController | ||
| include GradesHelper | ||
|
|
||
| GRADES_EXPORT_HEADERS = %w[username grade comment].freeze | ||
| GRADES_EXPORT_OPTIONAL_HEADERS = %w[email].freeze | ||
|
|
||
| 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', 'get_review_tableau_data' | ||
| when 'view_all_scores', 'get_review_tableau_data', 'export' | ||
| current_user_teaching_staff_of_assignment?(params[:assignment_id]) | ||
| when 'edit', 'assign_grade', 'instructor_review' | ||
| set_team_and_assignment_via_participant | ||
|
|
@@ -37,6 +42,20 @@ def view_all_scores | |
| } | ||
| end | ||
|
|
||
| # export (GET /grades/:assignment_id/export) | ||
| # Exports fixed, gradebook-friendly CSV columns for an assignment. | ||
| def export | ||
| assignment = Assignment.find(params[:assignment_id]) | ||
| filename = "#{assignment.name.parameterize.presence || 'assignment'}-grades.csv" | ||
|
|
||
| send_data( | ||
| grades_csv_for(assignment, include_email: include_email_in_grades_export?), | ||
| type: 'text/csv; charset=utf-8', | ||
| disposition: 'attachment', | ||
| filename: filename | ||
| ) | ||
| end | ||
|
|
||
|
|
||
| # view_our_scores (GET /grades/:assignment_id/view_our_scores) | ||
| # similar to view but scoped to the requesting student’s own team. | ||
|
|
@@ -245,6 +264,32 @@ def set_participant_and_team_via_assignment | |
| @assignment = @participant.assignment | ||
| end | ||
|
|
||
| # Export one row per assignment participant. A participant-specific grade | ||
| # overrides the team submission grade; submission comments remain team-level. | ||
| def grades_csv_for(assignment, include_email: false) | ||
| headers = GRADES_EXPORT_HEADERS + (include_email ? GRADES_EXPORT_OPTIONAL_HEADERS : []) | ||
|
|
||
| CSV.generate(headers: true) do |csv| | ||
| csv << headers | ||
|
|
||
| assignment.participants.includes(:user).find_each do |participant| | ||
| team = participant.team | ||
| row = [ | ||
| participant.user_name, | ||
| participant.grade || team&.grade_for_submission, | ||
| team&.comment_for_submission | ||
| ] | ||
| row << participant.user&.email if include_email | ||
|
|
||
| csv << row | ||
|
Comment on lines
+277
to
+284
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent CSV formula injection in export rows. Line 278/280/282 write user-controlled text directly into CSV cells. Spreadsheet clients can evaluate cells starting with 🔒 Proposed fix def grades_csv_for(assignment, include_email: false)
headers = GRADES_EXPORT_HEADERS + (include_email ? GRADES_EXPORT_OPTIONAL_HEADERS : [])
CSV.generate(headers: true) do |csv|
csv << headers
assignment.participants.includes(:user).find_each do |participant|
team = participant.team
row = [
- participant.user_name,
+ sanitize_csv_cell(participant.user_name),
participant.grade || team&.grade_for_submission,
- team&.comment_for_submission
+ sanitize_csv_cell(team&.comment_for_submission)
]
- row << participant.user&.email if include_email
+ row << sanitize_csv_cell(participant.user&.email) if include_email
csv << row
end
end
end
+
+def sanitize_csv_cell(value)
+ text = value.to_s
+ text.match?(/\A[=\-+@]/) ? "'#{text}" : text
+end🧰 Tools🪛 RuboCop (1.86.1)[convention] 278-278: Use 2 spaces for indentation in an array, relative to the start of the line where the left square bracket is. (Layout/FirstArrayElementIndentation) 🤖 Prompt for AI Agents |
||
| end | ||
| end | ||
| end | ||
|
|
||
| def include_email_in_grades_export? | ||
| ActiveModel::Type::Boolean.new.cast(params[:include_email]) | ||
| 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) | ||
|
|
@@ -374,4 +419,4 @@ def get_answer(score, index) | |
| reviewee_name: reviewee_name | ||
| } | ||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing required scope IDs can lead to unintended export scope.
For
Team,AssignmentParticipant, andProjectTopic,assignment_idshould be required; forCourseParticipant,course_idshould be required. Right now these branches execute even when IDs are blank.Suggested fix
def index klass = resolve_export_class(params[:class]) raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? + validate_scope_params!(klass) render json: export_metadata_for(klass), status: :ok rescue StandardError => e render json: { error: e.message }, status: :unprocessable_entity end @@ def export @@ klass = resolve_export_class(params[:class]) raise ArgumentError, "Unsupported export class: #{params[:class]}" if klass.nil? + validate_scope_params!(klass) @@ private + + def validate_scope_params!(klass) + if [Team, AssignmentParticipant, ProjectTopic].include?(klass) && params[:assignment_id].blank? + raise ArgumentError, "assignment_id is required for #{klass.name}" + end + if klass == CourseParticipant && params[:course_id].blank? + raise ArgumentError, "course_id is required for CourseParticipant" + end + endAlso applies to: 39-64, 84-122
🧰 Tools
🪛 RuboCop (1.86.1)
[convention] 23-23: Trailing whitespace detected.
(Layout/TrailingWhitespace)
🤖 Prompt for AI Agents