Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@
coverage/
rsa_keys.yml
pg_data/

# Ignore ruby version
.ruby-version
Gemfile
Gemfile.lock
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby-3.4.5
3.4.5
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ RUN bundle install
EXPOSE 3002

# Set the entry point
ENTRYPOINT ["/app/setup.sh"]
ENTRYPOINT ["/app/setup.sh"]
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ gem 'find_with_order'
# For handling zip file uploads and extraction
gem 'rubyzip'


group :development, :test do
gem 'debug', platforms: %i[mri mingw x64_mingw]
gem 'factory_bot_rails'
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ def action_allowed?
# GET /courses
# List all the courses
def index
courses = Course.all
render json: courses, status: :ok
courses = Course.includes(:assignments).all
render json: courses.as_json(include: :assignments), status: :ok
end

# GET /courses/1
Expand Down
191 changes: 181 additions & 10 deletions app/controllers/questionnaires_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,64 @@
class QuestionnairesController < ApplicationController
DISPLAY_TYPES = [
'Review',
'Metareview',
'Author feedback',
'Teammate Review',
'Survey',
'Assignment survey',
'Global survey',
'Course survey',
'Bookmark rating',
'Quiz'
].freeze

TYPE_DISPLAY_MAP = {
'ReviewQuestionnaire' => 'Review',
'MetareviewQuestionnaire' => 'Metareview',
'Author FeedbackQuestionnaire' => 'Author feedback',
'AuthorFeedbackQuestionnaire' => 'Author feedback',
'Teammate ReviewQuestionnaire' => 'Teammate Review',
'TeammateReviewQuestionnaire' => 'Teammate Review',
'SurveyQuestionnaire' => 'Survey',
'AssignmentSurveyQuestionnaire' => 'Assignment survey',
'Assignment SurveyQuestionnaire' => 'Assignment survey',
'Global SurveyQuestionnaire' => 'Global survey',
'GlobalSurveyQuestionnaire' => 'Global survey',
'Course SurveyQuestionnaire' => 'Course survey',
'CourseSurveyQuestionnaire' => 'Course survey',
'Bookmark RatingQuestionnaire' => 'Bookmark rating',
'BookmarkRatingQuestionnaire' => 'Bookmark rating',
'QuizQuestionnaire' => 'Quiz'
}.freeze

# Index method returns the list of JSON objects of the questionnaire
# GET on /questionnaires
def index
@questionnaires = Questionnaire.order(:id)
render json: @questionnaires, status: :ok and return
end

# Hierarchical list of questionnaire types and questionnaires available to the current user.
# GET on /questionnaires/hierarchical
def hierarchical
questionnaires = Questionnaire
.includes(:instructor)
.where(private: false)
.or(Questionnaire.where(instructor_id: current_user.id))
.order(:name)
.distinct

grouped_questionnaires = questionnaires.group_by do |questionnaire|
display_type_for(questionnaire.questionnaire_type)
end

render json: DISPLAY_TYPES.map { |display_type|
{
type: display_type,
questionnaires: (grouped_questionnaires[display_type] || []).map(&:as_json)
}
}, status: :ok and return
end

# Show method returns the JSON object of questionnaire with id = {:id}
# GET on /questionnaires/:id
Expand All @@ -17,18 +70,30 @@ def show
render json: $ERROR_INFO.to_s, status: :not_found and return
end
end

# GET /questionnaires/:id/items
def items
questionnaire = Questionnaire.find(params[:id])
render json: questionnaire.items.order(:seq), status: :ok and return
rescue ActiveRecord::RecordNotFound
render json: { error: "Questionnaire not found" }, status: :not_found and return
Comment on lines +75 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add access control in items to prevent private questionnaire data exposure.

Line 76 fetches by ID only; any authenticated user can request items for another instructor’s private questionnaire if they know the ID.

🔒 Proposed fix
 def items
-  questionnaire = Questionnaire.find(params[:id])
+  questionnaire = Questionnaire
+                  .where(private: false)
+                  .or(Questionnaire.where(instructor_id: current_user.id))
+                  .find(params[:id])
   render json: questionnaire.items.order(:seq), status: :ok and return
 rescue ActiveRecord::RecordNotFound
   render json: { error: "Questionnaire not found" }, status: :not_found and return
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questionnaires_controller.rb` around lines 75 - 79, The items
action currently uses Questionnaire.find(params[:id]) which exposes private
questionnaires to any authenticated user; change it to enforce access control by
locating the record through an ownership or visibility check (e.g. use
current_user.questionnaires.find(params[:id]) or Questionnaire.find_by(id:
params[:id]) and then require questionnaire.public? ||
questionnaire.instructor_id == current_user.id || current_user.admin?); if the
check fails, render a 403/404 (e.g. render json: { error: "Not authorized" },
status: :forbidden) and only then render questionnaire.items.order(:seq); keep
the existing rescue for RecordNotFound or switch to a nil-check and return
early.

end

# Create method creates a questionnaire and returns the JSON object of the created questionnaire
# POST on /questionnaires
# Instructor Id statically defined since implementation of Instructor model is out of scope of E2345.
def create
begin
@questionnaire = Questionnaire.new(questionnaire_params)
questionnaire_attributes, item_attributes = split_questionnaire_params
@questionnaire = Questionnaire.new(questionnaire_attributes)
@questionnaire.display_type = sanitize_display_type(@questionnaire.questionnaire_type)
@questionnaire.save!
Questionnaire.transaction do
@questionnaire.save!
sync_items!(@questionnaire, item_attributes)
end
render json: @questionnaire, status: :created and return
rescue ActiveRecord::RecordInvalid
render json: $ERROR_INFO.to_s, status: :unprocessable_entity
render json: { errors: $ERROR_INFO.record.errors.full_messages }, status: :unprocessable_entity
end
end

Expand All @@ -37,9 +102,12 @@ def create
def destroy
begin
@questionnaire = Questionnaire.find(params[:id])
@questionnaire.delete
@questionnaire.destroy!
render json: { message: 'Questionnaire deleted successfully' }, status: :ok and return
rescue ActiveRecord::RecordNotFound
render json: $ERROR_INFO.to_s, status: :not_found and return
rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::InvalidForeignKey
render json: { error: $ERROR_INFO.message }, status: :unprocessable_entity and return
end
end

Expand All @@ -48,11 +116,16 @@ def destroy

def update
@questionnaire = Questionnaire.find(params[:id])
if @questionnaire.update(questionnaire_params)
render json: @questionnaire, status: :ok
else
render json: @questionnaire.errors.full_messages, status: :unprocessable_entity
questionnaire_attributes, item_attributes = split_questionnaire_params

Questionnaire.transaction do
@questionnaire.update!(questionnaire_attributes)
sync_items!(@questionnaire, item_attributes)
end

render json: @questionnaire, status: :ok
rescue ActiveRecord::RecordInvalid
render json: { errors: $ERROR_INFO.record.errors.full_messages }, status: :unprocessable_entity
end
# Copy method creates a copy of questionnaire with id - {:id} and return its JSON object
# POST on /questionnaires/copy/:id
Expand Down Expand Up @@ -87,7 +160,31 @@ def toggle_access
private

def questionnaire_params
params.require(:questionnaire).permit(:name, :questionnaire_type, :private, :min_question_score, :max_question_score, :instructor_id)
params.require(:questionnaire).permit(
:name,
:questionnaire_type,
:private,
:min_question_score,
:max_question_score,
:instructor_id,
items_attributes: [
Comment on lines +163 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not permit client-controlled instructor_id in questionnaire params.

Permitting :instructor_id allows ownership spoofing unless tightly gated elsewhere. Ownership should be derived from current_user (or explicitly admin-only).

🛡️ Proposed fix
 def questionnaire_params
   params.require(:questionnaire).permit(
     :name,
     :questionnaire_type,
     :private,
     :min_question_score,
     :max_question_score,
-    :instructor_id,
     items_attributes: [
       :id,
       :txt,
       :question_type,
@@
# additionally in create/update assignment path
questionnaire_attributes[:instructor_id] = current_user.id
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questionnaires_controller.rb` around lines 163 - 170, Remove
:instructor_id from the strong params list (the
params.require(:questionnaire).permit(...) call) so clients cannot set
ownership, and in the controller actions that build/update questionnaires
(create and update) explicitly set the instructor on the attributes from the
authenticated user (e.g., assign questionnaire_attributes[:instructor_id] =
current_user.id before creating/updating the Questionnaire). Ensure this is done
in the same controller methods that call the permitted params so instructor_id
is derived server-side rather than accepted from the request.

:id,
:txt,
:question_type,
:weight,
:alternatives,
:min_label,
:max_label,
:seq,
:break_before,
:textarea_width,
:textarea_height,
:textbox_width,
:row_names,
:col_names,
:_destroy
]
)
end

def sanitize_display_type(type)
Expand All @@ -98,4 +195,78 @@ def sanitize_display_type(type)
display_type
end

end
def display_type_for(questionnaire_type)
TYPE_DISPLAY_MAP.fetch(questionnaire_type, questionnaire_type.to_s.delete_suffix('Questionnaire'))
end

def split_questionnaire_params
permitted_params = questionnaire_params.to_h.deep_symbolize_keys
items_attributes = permitted_params.delete(:items_attributes) || []
[permitted_params, items_attributes]
end

def sync_items!(questionnaire, item_attributes)
item_attributes.each_with_index do |item_data, index|
destroy_item = ActiveModel::Type::Boolean.new.cast(item_data[:_destroy])

if destroy_item && item_data[:id].present?
questionnaire.items.find(item_data[:id]).destroy!
next
end

next if destroy_item

if item_data[:id].present?
existing_item = questionnaire.items.find(item_data[:id])
attributes = build_item_attributes(item_data, index, existing_item)
existing_item.update!(attributes)
else
attributes = build_item_attributes(item_data, index)
questionnaire.items.create!(attributes)
end
end
end

def build_item_attributes(item_data, index, existing_item = nil)
question_type = canonical_question_type(item_data[:question_type])
{
txt: item_data[:txt].presence || existing_item&.txt,
question_type: question_type,
weight: item_data[:weight].presence || existing_item&.weight,
seq: item_data[:seq].presence || index + 1,
alternatives: normalize_alternatives(item_data[:alternatives]) || existing_item&.alternatives,
min_label: item_data[:min_label].presence || existing_item&.min_label,
max_label: item_data[:max_label].presence || existing_item&.max_label,
break_before: item_data.key?(:break_before) ? ActiveModel::Type::Boolean.new.cast(item_data[:break_before]) : true,
size: build_item_size(question_type, item_data, existing_item)
Comment on lines +233 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update path currently overwrites existing item values when fields are omitted.

Line 236 defaults missing seq to index + 1, and Line 240 defaults missing break_before to true. On partial updates, this can silently mutate existing item state.

🐛 Proposed fix
 def build_item_attributes(item_data, index, existing_item = nil)
   question_type = canonical_question_type(item_data[:question_type])
   {
     txt: item_data[:txt].presence || existing_item&.txt,
     question_type: question_type,
     weight: item_data[:weight].presence || existing_item&.weight,
-    seq: item_data[:seq].presence || index + 1,
+    seq: item_data[:seq].presence || existing_item&.seq || index + 1,
     alternatives: normalize_alternatives(item_data[:alternatives]) || existing_item&.alternatives,
     min_label: item_data[:min_label].presence || existing_item&.min_label,
     max_label: item_data[:max_label].presence || existing_item&.max_label,
-    break_before: item_data.key?(:break_before) ? ActiveModel::Type::Boolean.new.cast(item_data[:break_before]) : true,
+    break_before: if item_data.key?(:break_before)
+                    ActiveModel::Type::Boolean.new.cast(item_data[:break_before])
+                  elsif existing_item
+                    existing_item.break_before
+                  else
+                    true
+                  end,
     size: build_item_size(question_type, item_data, existing_item)
   }.compact
 end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
txt: item_data[:txt].presence || existing_item&.txt,
question_type: question_type,
weight: item_data[:weight].presence || existing_item&.weight,
seq: item_data[:seq].presence || index + 1,
alternatives: normalize_alternatives(item_data[:alternatives]) || existing_item&.alternatives,
min_label: item_data[:min_label].presence || existing_item&.min_label,
max_label: item_data[:max_label].presence || existing_item&.max_label,
break_before: item_data.key?(:break_before) ? ActiveModel::Type::Boolean.new.cast(item_data[:break_before]) : true,
size: build_item_size(question_type, item_data, existing_item)
txt: item_data[:txt].presence || existing_item&.txt,
question_type: question_type,
weight: item_data[:weight].presence || existing_item&.weight,
seq: item_data[:seq].presence || existing_item&.seq || index + 1,
alternatives: normalize_alternatives(item_data[:alternatives]) || existing_item&.alternatives,
min_label: item_data[:min_label].presence || existing_item&.min_label,
max_label: item_data[:max_label].presence || existing_item&.max_label,
break_before: if item_data.key?(:break_before)
ActiveModel::Type::Boolean.new.cast(item_data[:break_before])
elsif existing_item
existing_item.break_before
else
true
end,
size: build_item_size(question_type, item_data, existing_item)
🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 240-240: Line is too long. [121/120]

(Layout/LineLength)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/questionnaires_controller.rb` around lines 233 - 241, The
update currently overwrites existing item fields when keys are omitted; change
the assignment logic so seq and break_before only change when the incoming
payload includes them. For seq, set it to item_data[:seq] (or its
presence-casted value) if item_data.key?(:seq), otherwise fall back to
existing_item&.seq and only default to index + 1 when there is no existing_item;
for break_before, only cast and use
ActiveModel::Type::Boolean.new.cast(item_data[:break_before]) when
item_data.key?(:break_before), otherwise use existing_item&.break_before (do not
default to true). Keep other fields (normalize_alternatives, build_item_size,
etc.) as-is.

}.compact
end

def canonical_question_type(question_type)
{
'Text area' => 'TextArea',
'Text field' => 'TextField',
'Multiple choice' => 'MultipleChoiceRadio'
}.fetch(question_type, question_type)
end

def normalize_alternatives(alternatives)
return nil if alternatives.blank?

alternatives.to_s.split(',').map(&:strip).reject(&:empty?).join('|')
end

def build_item_size(question_type, item_data, existing_item = nil)
case question_type
when 'Criterion', 'TextArea'
width = item_data[:textarea_width].presence
height = item_data[:textarea_height].presence
return "#{width},#{height}" if width && height
when 'TextField'
return item_data[:textbox_width].to_s if item_data[:textbox_width].present?
end

existing_item&.size
end

end
6 changes: 5 additions & 1 deletion app/controllers/questions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ def delete_all
end

def types
types = Item.pluck(:question_type).uniq
types = if QuestionType.exists?
QuestionType.order(:name).pluck(:name)
else
%w[Criterion Scale Dropdown TextArea TextField MultipleChoiceRadio]
end
render json: types, status: :ok
end

Expand Down
22 changes: 19 additions & 3 deletions app/controllers/teams_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@ class TeamsController < ApplicationController
before_action :validate_team_type, only: [:create]

# GET /teams
# Fetches all teams and renders them using TeamSerializer
# Fetches teams for a hierarchy context (assignment/course) and renders them using TeamSerializer
def index
@teams = Team.all
@teams = Team.includes(:users, { teams_participants: :participant })

if params[:parent_id].present?
@teams = @teams.where(parent_id: params[:parent_id])
end

if params[:types].present?
requested_types = params[:types].is_a?(Array) ? params[:types] : params[:types].to_s.split(',')
normalized_types = requested_types.map(&:strip).reject(&:blank?)
@teams = @teams.where(type: normalized_types) if normalized_types.any?
end

@teams = @teams.order(:name, :id)
render json: @teams, each_serializer: TeamSerializer
end

Expand Down Expand Up @@ -94,7 +106,11 @@ def set_team

# Whitelists the parameters allowed for team creation/updation
def team_params
params.require(:team).permit(:name, :type, :assignment_id)
permitted = params.require(:team).permit(:name, :type, :parent_id, :assignment_id)

# Backward compatibility for clients still sending assignment_id.
permitted[:parent_id] = permitted[:assignment_id] if permitted[:parent_id].blank? && permitted[:assignment_id].present?
permitted.except(:assignment_id)
end

# Whitelists parameters required to add a team member
Expand Down
4 changes: 2 additions & 2 deletions app/models/Item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def set_seq

def as_json(options = {})
super(options.merge({
only: %i[txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at],
only: %i[id txt weight seq question_type size alternatives break_before min_label max_label created_at updated_at],
include: {
questionnaire: { only: %i[name id] }
}
Expand Down Expand Up @@ -91,4 +91,4 @@ def self.for(record)
# Cast the existing record to the desired subclass
klass.new(record.attributes)
end
end
end
2 changes: 2 additions & 0 deletions app/models/question_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class QuestionType < ApplicationRecord
end
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the current state of the QuestionType model
cat -n app/models/question_type.rb

Repository: expertiza/reimplementation-back-end

Length of output: 136


🏁 Script executed:

# Find the migration file for question_types table
fd -e rb -path "*db/migrate*" | xargs grep -l "QuestionType\|question_type" | head -5

Repository: expertiza/reimplementation-back-end

Length of output: 248


🏁 Script executed:

# Search for QuestionsController and the types method
fd -e rb -path "*controllers*" | xargs grep -l "QuestionsController" | head -3

Repository: expertiza/reimplementation-back-end

Length of output: 248


🏁 Script executed:

# Search for usage of QuestionType in the codebase
rg "QuestionType" --type rb -A 3 -B 3 | head -100

Repository: expertiza/reimplementation-back-end

Length of output: 106


🏁 Script executed:

# Find migration files related to question_type
git ls-files | grep -i "migrate" | xargs grep -l "question_type" 2>/dev/null || echo "Checking db/migrate directly..."
ls -la db/migrate/ | grep -i question

Repository: expertiza/reimplementation-back-end

Length of output: 968


🏁 Script executed:

# Find QuestionsController
git ls-files | grep -i "controller" | xargs grep -l "QuestionsController\|questions_controller" 2>/dev/null | head -5

Repository: expertiza/reimplementation-back-end

Length of output: 119


🏁 Script executed:

# Search for all references to QuestionType and types method
rg "QuestionType" --multiline -A 5 -B 2

Repository: expertiza/reimplementation-back-end

Length of output: 800


🏁 Script executed:

# Look for any validates statements in question_type model
rg "validates" app/models/question_type.rb

Repository: expertiza/reimplementation-back-end

Length of output: 61


🏁 Script executed:

# Read the migration file that creates question_tables
cat -n db/migrate/20250214224716_create_question_tables.rb

Repository: expertiza/reimplementation-back-end

Length of output: 1267


🏁 Script executed:

# Also check the other migration file
cat -n db/migrate/20230401213404_create_questions.rb

Repository: expertiza/reimplementation-back-end

Length of output: 777


Add presence and uniqueness constraints to the question_types table.

QuestionType drives a public endpoint but lacks both model validations and database constraints. The migration creates the :name column without NOT NULL or uniqueness, allowing blank or duplicate entries to corrupt the endpoint response.

Add model validations and update the migration to enforce constraints at the database level:

🛠️ Suggested fixes

Model validation:

 class QuestionType < ApplicationRecord
+  validates :name, presence: true, uniqueness: true
 end

Migration constraint (add to db/migrate/20250214224716_create_question_tables.rb):

 create_table :question_types, options: "CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci" do |t|
-  t.string :name
+  t.string :name, null: false
   t.timestamps
 end
+add_index :question_types, :name, unique: true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class QuestionType < ApplicationRecord
end
class QuestionType < ApplicationRecord
validates :name, presence: true, uniqueness: true
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/question_type.rb` around lines 1 - 2, Add presence and uniqueness
validation to the QuestionType model and enforce NOT NULL + uniqueness at the DB
level: update class QuestionType to include validates :name, presence: true,
uniqueness: true; then create a new migration (or update the
CreateQuestionTables migration) to (1) clean existing bad data (remove or
deduplicate blank/duplicate names), (2) change the question_types.name column to
NOT NULL, and (3) add a unique index on :name (unique: true) so the database
enforces uniqueness; reference the QuestionType model and the
CreateQuestionTables migration when locating the code to modify.

8 changes: 0 additions & 8 deletions app/models/questionnaire.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
class Questionnaire < ApplicationRecord
belongs_to :instructor
has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of items associated with this Questionnaire
before_destroy :check_for_question_associations

validate :validate_questionnaire
validates :name, presence: true
Expand Down Expand Up @@ -64,13 +63,6 @@ def self.copy_questionnaire_details(params)
questionnaire
end

# Check_for_question_associations checks if questionnaire has associated items or not
def check_for_question_associations
if items.any?
raise ActiveRecord::DeleteRestrictionError.new( "Cannot delete record because dependent items exist")
end
end

def as_json(options = {})
super(options.merge({
only: %i[id name private min_question_score max_question_score created_at updated_at questionnaire_type instructor_id],
Expand Down
2 changes: 1 addition & 1 deletion app/serializers/team_serializer.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

class TeamSerializer < ActiveModel::Serializer
attributes :id, :name, :type, :team_size
attributes :id, :name, :type, :parent_id, :team_size
has_many :members, serializer: ParticipantSerializer
has_many :users, serializer: UserSerializer

Expand Down
Loading
Loading