diff --git a/app/assets/javascripts/roster_setup.js b/app/assets/javascripts/roster_setup.js
new file mode 100644
index 0000000000..b01606f8a8
--- /dev/null
+++ b/app/assets/javascripts/roster_setup.js
@@ -0,0 +1,109 @@
+(function(){
+ var PROGRESS_HALF_LIFE = 1000;
+ var setup_roster_update_cable,
+ progress_asymptotically,
+ display_message,
+ set_progress,
+ display_progress_bar,
+ initialize_progress;
+
+ var progress_complete = false;
+ setup_roster_update_cable = function(){
+ var roster_id = $("#current_roster_id").val();
+ var user_id = $("#current_user_id").val();
+ App.add_students_to_roster = App.cable.subscriptions.create({
+ channel: "AddStudentsToRosterChannel",
+ roster_id: roster_id,
+ user_id: user_id
+ },
+ {
+ connected: function() {
+ // Called when the subscription is ready for use on the server
+ },
+
+ disconnected: function() {
+ // Called when the subscription has been terminated by the server
+ },
+
+ received: function(data) {
+ // Called when there's incoming data on the websocket for this channel
+ if(data.status == "completed"){
+ progress_complete = true;
+ set_progress(100);
+ display_message(data.message);
+ }
+ }
+ });
+ };
+
+ progress_asymptotically = function() {
+ recursive_progress_asymptotically = function(recursive_callback, counter) {
+ var progress;
+ var remaining = 100/counter;
+ progress = 100 - (100/counter);
+ $(document)
+ .find(".roster-update-progress-bar")
+ .animate(
+ { width: progress.toFixed() + "%" },
+ { duration: PROGRESS_HALF_LIFE * counter }
+ );
+ setTimeout(function() {
+ if(!progress_complete){
+ recursive_callback(recursive_callback, counter + 1);
+ }
+ },
+ PROGRESS_HALF_LIFE * counter
+ );
+ };
+ recursive_progress_asymptotically(recursive_progress_asymptotically, 1);
+};
+
+display_message = function(message){
+ $(".roster-update-message").removeAttr("hidden");
+ $("#roster-progress").text(message);
+};
+
+set_progress = function(percent) {
+ $(".roster-update-progress-bar").stop(true, false);
+ if (percent === 0) {
+ $(".roster-update-progress-bar").css("width", 0);
+ } else if (percent) {
+ $(".roster-update-progress-bar").animate({width: percent + "%"});
+ }
+};
+
+toggle_roster_form = function(disable_fields){
+ var text_area = $("#entries-field");
+ var csv_button = $("#file-upload");
+ if(disable_fields){
+ text_area.attr("disabled", "disabled");
+ csv_button.addClass("disabled");
+ }else{
+ text_area.removeAttr("disabled");
+ csv_button.removeClass("disabled");
+ }
+};
+
+ready = (function(){
+ $("#add-students-roster-form").on("ajax:beforeSend", function(){
+ toggle_roster_form(true);
+ $('.roster-update-progress').removeAttr("hidden");
+ progress_asymptotically();
+ });
+
+ $("#add-students-roster-form").on("ajax:complete", function(){
+ toggle_roster_form(false);
+ $("#entries-field").val("");
+ });
+
+ $(document).on('closing', '[data-remodal-id=new-student-modal]', function (e) {
+ set_progress("0");
+ $('.roster-update-progress').attr("hidden", "hidden");
+ });
+
+ setup_roster_update_cable();
+});
+
+$(document).ready(ready);
+
+}).call(this);
diff --git a/app/channels/add_students_to_roster_channel.rb b/app/channels/add_students_to_roster_channel.rb
new file mode 100644
index 0000000000..ae5dcdda76
--- /dev/null
+++ b/app/channels/add_students_to_roster_channel.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddStudentsToRosterChannel < ApplicationCable::Channel
+ def self.channel(user_id:, roster_id:)
+ "#{channel_name}_#{user_id}_#{roster_id}"
+ end
+
+ def subscribed
+ stream_from self.class.channel(roster_id: params[:roster_id], user_id: current_user.id)
+ end
+
+ def unsubscribed
+ # Any cleanup needed when channel is unsubscribed
+ end
+end
diff --git a/app/controllers/orgs/rosters_controller.rb b/app/controllers/orgs/rosters_controller.rb
index 6d39dd0e67..d8787a73dc 100644
--- a/app/controllers/orgs/rosters_controller.rb
+++ b/app/controllers/orgs/rosters_controller.rb
@@ -21,6 +21,7 @@ class RostersController < Orgs::Controller
depends_on :google_classroom
# rubocop:disable AbcSize
+ # rubocop:disable MethodLength
def show
@google_course_name = current_organization_google_course_name
@@ -34,9 +35,13 @@ def show
.order(:id)
.page(params[:unlinked_users_page])
+ # Used for displaying all roster entries in tabs
+ @roster_entries_count = current_roster.roster_entries.count
+
download_roster if params.dig("format")
end
# rubocop:enable AbcSize
+ # rubocop:enable MethodLength
def new
@roster = Roster.new
@@ -129,38 +134,19 @@ def edit_entry
redirect_to roster_path(current_organization, params: { roster_entries_page: params[:roster_entries_page] })
end
- # rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
def add_students
- if params[:lms_user_ids].is_a? String
- params[:lms_user_ids] = params[:lms_user_ids].split
- end
- identifiers = params[:identifiers].split("\r\n").reject(&:blank?)
- lms_ids = params[:lms_user_ids] || []
-
- begin
- entries = RosterEntry.create_entries(
- identifiers: identifiers,
- roster: current_roster,
- lms_user_ids: lms_ids
- )
-
- if entries.empty?
- flash[:warning] = "No students created."
- elsif entries.length == identifiers.length
- flash[:success] = "Students created."
- imported_students_lms_statsd(lms_user_ids: params[:lms_user_ids])
- else
- flash[:success] = "Students created. Some duplicates have been omitted."
- imported_students_lms_statsd(lms_user_ids: params[:lms_user_ids])
- end
- rescue RosterEntry::IdentifierCreationError
- flash[:error] = "An error has occurred. Please try again."
- end
+ params[:lms_user_ids].split! if params[:lms_user_ids].is_a? String
+ lms_user_ids = Array.wrap(params[:lms_user_ids])
- redirect_to roster_path(current_organization)
+ identifiers = params[:identifiers].split("\r\n").reject(&:blank?)
+ job_info = AddStudentsToRosterJob.perform_later(identifiers, current_roster, current_user, lms_user_ids)
+ render json: { job_info: job_info }
end
+ # rubocop:enable Metrics/AbcSize
+ # rubocop:disable Metrics/MethodLength
+ # rubocop:disable Metrics/AbcSize
def download_roster
grouping = current_organization.groupings.find(params[:grouping]) if params[:grouping]
@@ -177,7 +163,6 @@ def download_roster
end
end
end
-
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
@@ -194,7 +179,7 @@ def create_statsd(lms_user_ids:)
end
def imported_students_lms_statsd(lms_user_ids:)
- return if lms_user_ids.nil?
+ return if lms_user_ids.blank?
GitHubClassroom.statsd.increment("roster_entries.lms_imported", by: lms_user_ids.length)
end
diff --git a/app/jobs/add_students_to_roster_job.rb b/app/jobs/add_students_to_roster_job.rb
new file mode 100644
index 0000000000..6a0b790a61
--- /dev/null
+++ b/app/jobs/add_students_to_roster_job.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class AddStudentsToRosterJob < ApplicationJob
+ queue_as :roster
+
+ ROSTER_UPDATE_SUCCESSFUL = "Roster successfully updated."
+ ROSTER_UPDATE_FAILED = "Could not add any students to roster, please try again."
+ ROSTER_UPDATE_PARTIAL_SUCCESS = "Could not add following students:"
+
+ # Takes an array of identifiers and creates a
+ # roster entry for each. Omits duplicates, and
+ #
+ # rubocop:disable AbcSize
+ # rubocop:disable MethodLength
+ def perform(identifiers, roster, user, lms_user_ids = [])
+ channel = AddStudentsToRosterChannel.channel(roster_id: roster.id, user_id: user.id)
+
+ identifiers = add_suffix_to_duplicates!(identifiers, roster)
+ invalid_roster_entries =
+ identifiers.zip(lms_user_ids).map do |identifier, lms_user_id|
+ roster_entry = RosterEntry.create(identifier: identifier, roster: roster, lms_user_id: lms_user_id)
+ roster_entry.identifier if roster_entry.errors.include?(:identifier)
+ end.compact!
+
+ message = build_message(invalid_roster_entries, identifiers)
+ entries_created = identifiers.count - invalid_roster_entries.count
+ if lms_user_ids.present? && entries_created.positive?
+ GitHubClassroom.statsd.increment("roster_entries.lms_imported", by: entries_created)
+ end
+ ActionCable.server.broadcast(channel, message: message, status: "completed")
+ end
+ # rubocop:enable AbcSize
+ # rubocop:enable MethodLength
+
+ def add_suffix_to_duplicates!(identifiers, roster)
+ existing_roster_entries = RosterEntry.where(roster: roster).pluck(:identifier)
+ RosterEntry.add_suffix_to_duplicates(
+ identifiers: identifiers,
+ existing_roster_entries: existing_roster_entries
+ )
+ end
+
+ # rubocop:disable MethodLength
+ def build_message(invalid_roster_entries, identifiers)
+ if invalid_roster_entries.empty?
+ ROSTER_UPDATE_SUCCESSFUL + " #{identifiers.count} roster #{'entries'.pluralize(identifiers.count)} were added."
+ elsif invalid_roster_entries.size == identifiers.size
+ ROSTER_UPDATE_FAILED
+ else
+ formatted_students =
+ invalid_roster_entries.map do |invalid_roster_entry|
+ "#{invalid_roster_entry} \n"
+ end.join("")
+ "#{ROSTER_UPDATE_PARTIAL_SUCCESS} \n#{formatted_students}"
+ end
+ end
+ # rubocop:enable MethodLength
+end
diff --git a/app/models/roster_entry.rb b/app/models/roster_entry.rb
index cb06245c66..935e9dec53 100644
--- a/app/models/roster_entry.rb
+++ b/app/models/roster_entry.rb
@@ -112,35 +112,4 @@ def self.students_not_on_team(group_assignment)
.uniq
where(user_id: nil).or(where.not(user_id: students_on_team))
end
-
- # Takes an array of identifiers and creates a
- # roster entry for each. Omits duplicates, and
- # raises IdentifierCreationError if there is an
- # error.
- #
- # Returns the created entries.
-
- # rubocop:disable Metrics/MethodLength
- def self.create_entries(identifiers:, roster:, lms_user_ids: [])
- created_entries = []
- RosterEntry.transaction do
- identifiers = add_suffix_to_duplicates(
- identifiers: identifiers,
- existing_roster_entries: RosterEntry.where(roster: roster).pluck(:identifier)
- )
-
- identifiers.zip(lms_user_ids).each do |identifier, lms_user_id|
- roster_entry = RosterEntry.create(identifier: identifier, roster: roster, lms_user_id: lms_user_id)
-
- if !roster_entry.persisted?
- raise IdentifierCreationError unless roster_entry.errors.include?(:identifier)
- else
- created_entries << roster_entry
- end
- end
- end
-
- created_entries
- end
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
end
diff --git a/app/views/orgs/rosters/_new_student_modal.html.erb b/app/views/orgs/rosters/_new_student_modal.html.erb
index 105b281f38..e8cd62c0e1 100644
--- a/app/views/orgs/rosters/_new_student_modal.html.erb
+++ b/app/views/orgs/rosters/_new_student_modal.html.erb
@@ -4,6 +4,27 @@
+ <%= hidden_field_tag 'user_id', current_user.id, id: "current_user_id" %>
+ <%= hidden_field_tag 'roster_id', roster.id, id: "current_roster_id" %>
+
+
+
+
+
+
+
Adding students to Roster
+
+
+
+
+
+
+
+
+
Import students from your institution
GitHub Classroom is able to automatically import your roster from your institution. If you
@@ -18,7 +39,7 @@
Manually add students
- <%= form_tag add_students_roster_path(current_organization), method: :patch do |f| %>
+ <%= form_tag add_students_roster_path(current_organization), method: :patch, remote: true, id: "add-students-roster-form" do |f| %>
- Enter your list of students' identifiers, one per line.
@@ -32,8 +53,8 @@
-
- <%= submit_tag 'Add roster entries', class: 'btn btn-primary mr-3' %>
+
+ <%= submit_tag 'Add roster entries', class: 'btn btn-primary mr-3', data: { "disable-with": "Updating..." } %>
<% end %>
diff --git a/app/views/orgs/rosters/show.html.erb b/app/views/orgs/rosters/show.html.erb
index 1c3ced812a..61ff4d7fa4 100644
--- a/app/views/orgs/rosters/show.html.erb
+++ b/app/views/orgs/rosters/show.html.erb
@@ -29,8 +29,8 @@
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index b6a1b3409d..91cd108b26 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -13,3 +13,4 @@
- [boom, 3]
- [create_repository, 3]
- [porter_status, 3]
+ - [roster, 3]
diff --git a/spec/controllers/orgs/rosters_controller_spec.rb b/spec/controllers/orgs/rosters_controller_spec.rb
index 75555ec82a..bada650f56 100644
--- a/spec/controllers/orgs/rosters_controller_spec.rb
+++ b/spec/controllers/orgs/rosters_controller_spec.rb
@@ -494,11 +494,13 @@
it "sends successfully" do
expect(GitHubClassroom.statsd).to receive(:increment).with("roster_entries.lms_imported", by: 2)
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb",
- lms_user_ids: [1, 2]
- }
+ perform_enqueued_jobs do
+ patch :add_students, params: {
+ id: organization.slug,
+ identifiers: "a\r\nb",
+ lms_user_ids: [1, 2]
+ }
+ end
end
end
@@ -510,96 +512,25 @@
it "sends successfully" do
expect(GitHubClassroom.statsd).to_not receive(:increment).with("roster_entries.lms_imported", by: 2)
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb",
- lms_user_ids: [1, 2]
- }
+ perform_enqueued_jobs do
+ patch :add_students, params: {
+ id: organization.slug,
+ identifiers: "a\r\nb",
+ lms_user_ids: [1, 2]
+ }
+ end
end
end
end
context "when all identifiers are valid" do
- before do
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb"
- }
- end
-
- it "redirects to rosters page" do
- expect(response).to redirect_to(roster_url(organization))
- end
-
- it "sets success message" do
- expect(flash[:success]).to eq("Students created.")
- end
-
- it "creates the student on the roster" do
- expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "a"))
- end
- end
-
- context "when there are duplicate identifiers" do
- before do
- create(:roster_entry, roster: roster, identifier: "a")
- create(:roster_entry, roster: roster, identifier: "b")
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb"
- }
- end
-
- it "redirects to rosters page" do
- expect(response).to redirect_to(roster_url(organization))
- end
-
- it "sets flash message" do
- expect(flash[:success]).to eq("Students created.")
- end
-
- it "creates roster entries" do
- expect do
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb"
- }
- end.to change(roster.reload.roster_entries, :count).by(2)
- end
-
- it "finds identifiers with suffix" do
- expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "a-1"))
- expect(roster.reload.roster_entries).to include(RosterEntry.find_by(identifier: "b-1"))
- end
- end
-
- context "when there's an internal error" do
- before do
- errored_entry = RosterEntry.new(roster: roster)
- errored_entry.errors[:base] << "Something went wrong ¯\\_(ツ)_/¯ "
- allow(RosterEntry).to receive(:create).and_return(errored_entry)
-
- patch :add_students, params: {
- id: organization.slug,
- identifiers: "a\r\nb"
- }
- end
-
- it "redirects to rosters page" do
- expect(response).to redirect_to(roster_url(organization))
- end
-
- it "sets flash message" do
- expect(flash[:error]).to eq("An error has occurred. Please try again.")
- end
-
- it "creates no roster entries" do
+ it "enqueues the AddStudentsToRosterJob" do
expect do
patch :add_students, params: {
id: organization.slug,
identifiers: "a\r\nb"
}
- end.to change(roster.reload.roster_entries, :count).by(0)
+ end.to have_enqueued_job(AddStudentsToRosterJob)
end
end
end
@@ -916,6 +847,7 @@
student_profiles = student_names.map do |name|
GoogleAPI::UserProfile.new(name: GoogleAPI::Name.new(full_name: name))
end
+
@students = student_profiles.map do |prof|
GoogleAPI::Student.new(profile: prof, user_id: SecureRandom.uuid)
end
@@ -924,7 +856,9 @@
.to receive(:students)
.and_return(@students)
- patch :sync_google_classroom, params: { id: organization.slug }
+ perform_enqueued_jobs do
+ patch :sync_google_classroom, params: { id: organization.slug }
+ end
end
it "adds the new student to the roster" do
diff --git a/spec/jobs/add_students_to_roster_job_spec.rb b/spec/jobs/add_students_to_roster_job_spec.rb
new file mode 100644
index 0000000000..19acd58174
--- /dev/null
+++ b/spec/jobs/add_students_to_roster_job_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe AddStudentsToRosterJob, type: :job do
+ let(:organization) { classroom_org }
+ let(:user) { classroom_teacher }
+ let!(:roster) { create(:roster) }
+ let(:channel) { AddStudentsToRosterChannel.channel(roster_id: roster.id, user_id: user.id) }
+ let(:identifiers) { %w[John Bob] }
+ let(:lms_user_ids) { [1, 2] }
+
+ after do
+ roster.roster_entries.destroy_all
+ end
+
+ describe "perform" do
+ context "add Roster Entries to roster" do
+ context "without lms_user_ids" do
+ it "adds new identifiers to roster" do
+ expect { described_class.perform_now(identifiers, roster, user) }.to change { RosterEntry.count }.by(2)
+ end
+ end
+ context "with lms_user_ids" do
+ it "adds lms_user_ids to correct roster entry" do
+ described_class.perform_now(identifiers, roster, user, lms_user_ids)
+ entry1 = RosterEntry.find_by(identifier: "John")
+ entry2 = RosterEntry.find_by(identifier: "Bob")
+ expect(entry1.lms_user_id).to eq("1")
+ expect(entry2.lms_user_id).to eq("2")
+ end
+ end
+ end
+
+ context "sends ActionCable broadcastss" do
+ it "sends completed message when job ends" do
+ expect { described_class.perform_now(identifiers, roster, user) }
+ .to have_broadcasted_to(channel)
+ .with(hash_including(status: "completed"))
+ end
+ end
+
+ context "sends DataDog stats" do
+ context "with google or lti integration" do
+ context "sends out statsd when successful" do
+ before do
+ create(:lti_configuration, organization: organization)
+ end
+
+ it "sends successfully" do
+ expect(GitHubClassroom.statsd).to receive(:increment).with("roster_entries.lms_imported", by: 2)
+ described_class.perform_now(identifiers, roster, user, lms_user_ids)
+ end
+ end
+
+ context "does not send out statsd when not successful" do
+ before(:each) do
+ end
+ it "sends successfully" do
+ expect(GitHubClassroom.statsd).to_not receive(:increment).with("roster_entries.lms_imported", by: 2)
+ described_class.perform_now(identifiers, roster, user, [])
+ end
+ end
+ end
+ end
+ end
+
+ describe "#build_message" do
+ let(:identifiers) { %w[John Bob] }
+
+ context "when all roster entries are valid" do
+ let(:invalid_roster_entries) { [] }
+
+ let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) }
+ it "returns roster update successful message" do
+ expect(result).to eq("Roster successfully updated. 2 roster entries were added.")
+ end
+ end
+
+ context "when all roster entries are invalid" do
+ let(:invalid_roster_entries) { %w[John Bob] }
+
+ let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) }
+ it "returns roster update failed message" do
+ expect(result).to eq("Could not add any students to roster, please try again.")
+ end
+ end
+
+ context "when some roster entries are invalid" do
+ let(:invalid_roster_entries) { %w[Bob] }
+
+ let(:result) { described_class.new.build_message(invalid_roster_entries, identifiers) }
+ it "returns partial roster update successful message" do
+ expect(result).to eq("Could not add following students: \nBob \n")
+ end
+ end
+ end
+
+ describe "#add_suffix_to_duplicates!" do
+ context "when duplicates exist" do
+ before do
+ RosterEntry.create(identifier: "John", roster: roster)
+ end
+
+ let(:result) do
+ described_class.new.add_suffix_to_duplicates!(%w[John Bob], roster)
+ end
+
+ it "adds suffix to duplicate entries" do
+ expect(result.length).to eq(2)
+ expect(result[0]).to eq("John-1")
+ end
+ end
+ context "when no duplicates exist" do
+ before do
+ roster.roster_entries.destroy_all
+ end
+
+ let(:result) do
+ described_class.new.add_suffix_to_duplicates!(%w[John Bob], roster)
+ end
+
+ it "keeps the entries the same" do
+ expect(result.length).to eq(2)
+ expect(result[0]).to eq("John")
+ end
+ end
+ end
+end
diff --git a/spec/models/roster_entry_spec.rb b/spec/models/roster_entry_spec.rb
index 79aee9a2a1..d0c47d6426 100644
--- a/spec/models/roster_entry_spec.rb
+++ b/spec/models/roster_entry_spec.rb
@@ -131,53 +131,4 @@
expect(roster_entries).to match_array([earliest_student_entry, initial_entry])
end
end
-
- describe "create_entries" do
- let(:organization) { classroom_org }
- let(:roster) { create(:roster) }
-
- context "all entries valid" do
- let(:result) do
- RosterEntry.create_entries(identifiers: %w[1 2], roster: roster)
- end
-
- it "creates two roster entries" do
- expect(result.length).to eq(2)
-
- expect(result[0]).to eq(RosterEntry.find_by(identifier: "1", roster: roster))
- expect(result[1]).to eq(RosterEntry.find_by(identifier: "2", roster: roster))
- end
- end
-
- context "when duplicate entries" do
- before do
- RosterEntry.create(identifier: "John", roster: roster)
- end
-
- let(:result) do
- RosterEntry.create_entries(identifiers: %w[John Bob], roster: roster)
- end
-
- it "creates adds suffix to duplicate entries" do
- expect(result.length).to eq(2)
-
- expect(result[0]).to eq(RosterEntry.find_by(identifier: "John-1", roster: roster))
- end
- end
-
- context "some other error" do
- before do
- errored_entry = RosterEntry.new(roster: roster)
- errored_entry.errors[:base] << "Something went wrong ¯\\_(ツ)_/¯ "
-
- allow(RosterEntry).to receive(:create).and_return(errored_entry)
- end
-
- it "raises RosterEntry::IdentifierCreationError" do
- expect do
- RosterEntry.create_entries(identifiers: %w[1], roster: roster)
- end.to raise_error(RosterEntry::IdentifierCreationError)
- end
- end
- end
end
diff --git a/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json b/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json
new file mode 100644
index 0000000000..969a6fc64e
--- /dev/null
+++ b/spec/support/cassettes/Orgs_RostersController/PATCH_add_students/when_all_identifiers_are_valid/enqueues_the_AddStudentsToRosterJob.json
@@ -0,0 +1 @@
+{"http_interactions":[{"request":{"method":"get","uri":"https://api.github.com/applications/\u003cTEST_APPLICATION_GITHUB_CLIENT_ID\u003e/tokens/\u003cTEST_CLASSROOM_OWNER_GITHUB_TOKEN\u003e","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"Accept":["application/vnd.github.v3+json"],"User-Agent":["Octokit Ruby Gem 4.14.0"],"Content-Type":["application/json"],"Cache-Control":["no-cache, no-store"],"Accept-Encoding":["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"]}},"response":{"status":{"code":200,"message":"OK"},"headers":{"Date":["Wed, 14 Aug 2019 23:00:29 GMT"],"Content-Type":["application/json; charset=utf-8"],"Transfer-Encoding":["chunked"],"Server":["GitHub.com"],"Status":["200 OK"],"X-Ratelimit-Limit":["5000"],"X-Ratelimit-Remaining":["4999"],"X-Ratelimit-Reset":["1565827229"],"Cache-Control":["public, max-age=60, s-maxage=60"],"Vary":["Accept","Accept-Encoding"],"Etag":["W/\"d79eae737c3ed77abd65ff87e5a7bb7e\""],"X-Github-Media-Type":["github.v3; format=json"],"Access-Control-Expose-Headers":["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type"],"Access-Control-Allow-Origin":["*"],"Strict-Transport-Security":["max-age=31536000; includeSubdomains; preload"],"X-Frame-Options":["deny"],"X-Content-Type-Options":["nosniff"],"X-Xss-Protection":["1; mode=block"],"Referrer-Policy":["origin-when-cross-origin, strict-origin-when-cross-origin"],"Content-Security-Policy":["default-src 'none'"],"X-Github-Request-Id":["E62A:6011:12903E:16764A:5D54928D"]},"body":{"encoding":"ASCII-8BIT","base64_string":"eyJpZCI6Mjk4MDg5Mzk5LCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29t\nL2F1dGhvcml6YXRpb25zLzI5ODA4OTM5OSIsImFwcCI6eyJuYW1lIjoiQ2xh\nc3Nyb29tIFRlc3QiLCJ1cmwiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJj\nbGllbnRfaWQiOiI8VEVTVF9BUFBMSUNBVElPTl9HSVRIVUJfQ0xJRU5UX0lE\nPiJ9LCJ0b2tlbiI6IjxURVNUX0NMQVNTUk9PTV9PV05FUl9HSVRIVUJfVE9L\nRU4+IiwiaGFzaGVkX3Rva2VuIjoiODgxZDY5NmQ1ZDM0ZDE4NDYzYzUyM2Uy\nNDA4MGNkNzhiNjhiOGMyMjMyMzRiZjc2MjllNzQ1OTRlMDNhZjRhOSIsInRv\na2VuX2xhc3RfZWlnaHQiOiI5NDYxZThkMCIsIm5vdGUiOm51bGwsIm5vdGVf\ndXJsIjpudWxsLCJjcmVhdGVkX2F0IjoiMjAxOS0wNi0xMVQxODozOTozOVoi\nLCJ1cGRhdGVkX2F0IjoiMjAxOS0wNi0xMVQxODozOTozOVoiLCJzY29wZXMi\nOlsiYWRtaW46b3JnIiwiYWRtaW46b3JnX2hvb2siLCJkZWxldGVfcmVwbyIs\nInJlcG8iLCJ1c2VyOmVtYWlsIl0sImZpbmdlcnByaW50IjpudWxsLCJ1c2Vy\nIjp7ImxvZ2luIjoic2hhdW5ha3BwLXRlc3QtMSIsImlkIjo8VEVTVF9DTEFT\nU1JPT01fT1dORVJfR0lUSFVCX0lEPiwibm9kZV9pZCI6Ik1EUTZWWE5sY2pV\neE56RTVNVGt6IiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFyczEuZ2l0\naHVidXNlcmNvbnRlbnQuY29tL3UvPFRFU1RfQ0xBU1NST09NX09XTkVSX0dJ\nVEhVQl9JRD4/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczov\nL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEiLCJodG1s\nX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9zaGF1bmFrcHAtdGVzdC0xIiwi\nZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMv\nc2hhdW5ha3BwLXRlc3QtMS9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoi\naHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0x\nL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczov\nL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEvZ2lzdHN7\nL2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIu\nY29tL3VzZXJzL3NoYXVuYWtwcC10ZXN0LTEvc3RhcnJlZHsvb3duZXJ9ey9y\nZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHVi\nLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3N1YnNjcmlwdGlvbnMiLCJv\ncmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNl\ncnMvc2hhdW5ha3BwLXRlc3QtMS9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6\nLy9hcGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3JlcG9z\nIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMv\nc2hhdW5ha3BwLXRlc3QtMS9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRf\nZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hh\ndW5ha3BwLXRlc3QtMS9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIs\nInNpdGVfYWRtaW4iOmZhbHNlfX0=\n"},"http_version":null},"recorded_at":"Wed, 14 Aug 2019 23:00:29 GMT"},{"request":{"method":"get","uri":"https://api.github.com/user","body":{"encoding":"US-ASCII","base64_string":""},"headers":{"Accept":["application/vnd.github.v3+json"],"User-Agent":["Octokit Ruby Gem 4.14.0"],"Content-Type":["application/json"],"Cache-Control":["no-cache, no-store"],"Accept-Encoding":["gzip;q=1.0,deflate;q=0.6,identity;q=0.3"]}},"response":{"status":{"code":200,"message":"OK"},"headers":{"Date":["Wed, 14 Aug 2019 23:00:30 GMT"],"Content-Type":["application/json; charset=utf-8"],"Transfer-Encoding":["chunked"],"Server":["GitHub.com"],"Status":["200 OK"],"X-Ratelimit-Limit":["5000"],"X-Ratelimit-Remaining":["4999"],"X-Ratelimit-Reset":["1565827230"],"Cache-Control":["private, max-age=60, s-maxage=60"],"Vary":["Accept, Authorization, Cookie, X-GitHub-OTP","Accept-Encoding"],"Etag":["W/\"a8625999343a045e665ed4e19e407b2d\""],"Last-Modified":["Tue, 06 Aug 2019 00:38:56 GMT"],"X-Oauth-Scopes":["admin:org, admin:org_hook, delete_repo, repo, user:email"],"X-Accepted-Oauth-Scopes":[""],"X-Oauth-Client-Id":["\u003cTEST_APPLICATION_GITHUB_CLIENT_ID\u003e"],"X-Github-Media-Type":["github.v3; format=json"],"Access-Control-Expose-Headers":["ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type"],"Access-Control-Allow-Origin":["*"],"Strict-Transport-Security":["max-age=31536000; includeSubdomains; preload"],"X-Frame-Options":["deny"],"X-Content-Type-Options":["nosniff"],"X-Xss-Protection":["1; mode=block"],"Referrer-Policy":["origin-when-cross-origin, strict-origin-when-cross-origin"],"Content-Security-Policy":["default-src 'none'"],"X-Github-Request-Id":["E62B:9F46:26B773:2EB719:5D54928E"]},"body":{"encoding":"ASCII-8BIT","base64_string":"eyJsb2dpbiI6InNoYXVuYWtwcC10ZXN0LTEiLCJpZCI6PFRFU1RfQ0xBU1NS\nT09NX09XTkVSX0dJVEhVQl9JRD4sIm5vZGVfaWQiOiJNRFE2VlhObGNqVXhO\nekU1TVRreiIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMxLmdpdGh1\nYnVzZXJjb250ZW50LmNvbS91LzxURVNUX0NMQVNTUk9PTV9PV05FUl9HSVRI\nVUJfSUQ+P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9h\ncGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xIiwiaHRtbF91\ncmwiOiJodHRwczovL2dpdGh1Yi5jb20vc2hhdW5ha3BwLXRlc3QtMSIsImZv\nbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3No\nYXVuYWtwcC10ZXN0LTEvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0\ndHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9m\nb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9h\ncGkuZ2l0aHViLmNvbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL2dpc3Rzey9n\naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNv\nbS91c2Vycy9zaGF1bmFrcHAtdGVzdC0xL3N0YXJyZWR7L293bmVyfXsvcmVw\nb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5j\nb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9zdWJzY3JpcHRpb25zIiwib3Jn\nYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJz\nL3NoYXVuYWtwcC10ZXN0LTEvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8v\nYXBpLmdpdGh1Yi5jb20vdXNlcnMvc2hhdW5ha3BwLXRlc3QtMS9yZXBvcyIs\nImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3No\nYXVuYWtwcC10ZXN0LTEvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2\nZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3NoYXVu\nYWtwcC10ZXN0LTEvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJz\naXRlX2FkbWluIjpmYWxzZSwibmFtZSI6bnVsbCwiY29tcGFueSI6bnVsbCwi\nYmxvZyI6IiIsImxvY2F0aW9uIjpudWxsLCJlbWFpbCI6bnVsbCwiaGlyZWFi\nbGUiOm51bGwsImJpbyI6bnVsbCwicHVibGljX3JlcG9zIjoxLCJwdWJsaWNf\nZ2lzdHMiOjAsImZvbGxvd2VycyI6MCwiZm9sbG93aW5nIjowLCJjcmVhdGVk\nX2F0IjoiMjAxOS0wNi0xMVQxODoyNjo0MloiLCJ1cGRhdGVkX2F0IjoiMjAx\nOS0wOC0wNlQwMDozODo1NloifQ==\n"},"http_version":null},"recorded_at":"Wed, 14 Aug 2019 23:00:30 GMT"}],"recorded_with":"VCR 3.0.3"}
\ No newline at end of file