Add waiting room to create programming groups (#1919)

Co-authored-by: Sebastian Serth <Sebastian.Serth@hpi.de>
This commit is contained in:
Kira Grammel
2023-09-21 15:07:10 +02:00
committed by GitHub
parent 1dfc306e76
commit 9f837412c7
15 changed files with 174 additions and 48 deletions

View File

@ -3,10 +3,9 @@ $(document).on('turbolinks:load', function () {
if ($.isController('programming_groups') && window.location.pathname.includes('programming_groups/new')) {
const matching_page = $('#matching');
const exercise_id = matching_page.data('exercise-id');
const specific_channel = { channel: "PgMatchingChannel", exercise_id: exercise_id};
App.pg_matching = App.cable.subscriptions.create({
channel: "PgMatchingChannel", exercise_id: exercise_id
}, {
App.pg_matching = App.cable.subscriptions.create(specific_channel, {
connected() {
// Called when the subscription is ready for use on the server
},
@ -23,8 +22,17 @@ $(document).on('turbolinks:load', function () {
window.location.reload();
}
break;
case 'joined_pg':
if (ProgrammingGroups.contains_own_user(data.users)) {
window.location.reload();
}
break;
}
},
waiting_for_match() {
this.perform('waiting_for_match');
}
});
}
});

View File

@ -20,6 +20,10 @@ var ProgrammingGroups = {
is_other_session: function (other_session_id) {
return this.session_id !== other_session_id;
},
contains_own_user: function (users) {
return users.find(e => ProgrammingGroups.is_other_user(e) === false) !== undefined
}
};
$(document).on('turbolinks:load', function () {
@ -35,4 +39,12 @@ $(document).on('turbolinks:load', function () {
new bootstrap.Modal(modal).show();
}
}
const join_pair_button = $('.join_programming_pair');
if (join_pair_button.isPresent()) {
join_pair_button.on('click', function() {
App.pg_matching?.waiting_for_match();
CodeOceanEditor.showSpinner(join_pair_button);
});
}
});

View File

@ -8,6 +8,8 @@ class PgMatchingChannel < ApplicationCable::Channel
def unsubscribed
# Any cleanup needed when channel is unsubscribed
@current_waiting_user.status_disconnected! if @current_waiting_user&.reload&.status_waiting?
stop_all_streams
end
@ -15,8 +17,29 @@ class PgMatchingChannel < ApplicationCable::Channel
"pg_matching_channel_exercise_#{@exercise.id}"
end
def waiting_for_match
@current_waiting_user = PairProgrammingWaitingUser.find_or_initialize_by(user: current_user, exercise: @exercise)
@current_waiting_user.status_waiting!
match_waiting_users
end
private
def match_waiting_users
# Check if there is another waiting user for this exercise
waiting_user = PairProgrammingWaitingUser.where(exercise: @exercise, status: :waiting).where.not(user: current_user).order(created_at: :asc).first
return if waiting_user.blank?
# If there is another waiting user, create a programming group with both users
match = [waiting_user, @current_waiting_user]
# Create the programming group. Note that an unhandled exception will be raised if the programming group
# is not valid (i.e., if one of the users already joined a programming group for this exercise).
pg = ProgrammingGroup.create!(exercise: @exercise, users: match.map(&:user))
match.each {|wu| wu.update(status: :created_pg, programming_group: pg) }
ActionCable.server.broadcast(specific_channel, {action: 'joined_pg', users: pg.users.map(&:to_page_context)})
end
def set_and_authorize_exercise
@exercise = Exercise.find(params[:exercise_id])
reject unless ExercisePolicy.new(current_user, @exercise).implement?

View File

@ -299,7 +299,7 @@ class ExercisesController < ApplicationController
private :update_exercise_tips
def implement
def implement # rubocop:disable Metrics/CyclomaticComplexity
if session[:pg_id] && current_contributor.exercise != @exercise
# we are acting on behalf of a programming group
if current_user.admin?
@ -315,8 +315,9 @@ class ExercisesController < ApplicationController
# we are just acting on behalf of a single user who has already worked on this exercise as part of a programming group **in the context of the current study group**
session[:pg_id] = pg.id
@current_contributor = pg
elsif PairProgramming23Study.participate?(current_user, @exercise) && current_user.submissions.where(study_group_id: current_user.current_study_group_id, exercise: @exercise).any?
elsif PairProgramming23Study.participate?(current_user, @exercise) && current_user.submissions.where(study_group_id: current_user.current_study_group_id, exercise: @exercise).none?
Event.create(category: 'pp_work_alone', user: current_user, exercise: @exercise, data: nil, file_id: nil)
current_user.pair_programming_waiting_users&.find_by(exercise: @exercise)&.update(status: :worked_alone)
end
user_solved_exercise = @exercise.solved_by?(current_contributor)

View File

@ -53,6 +53,9 @@ class ProgrammingGroupsController < ApplicationController
ActionCable.server.broadcast("pg_matching_channel_exercise_#{@exercise.id}", message)
end
# Check if the user was waiting for a programming group match and update the status
current_user.pair_programming_waiting_users&.find_by(exercise: @exercise)&.update(status: :created_pg, programming_group: @programming_group)
# Just set the programming group id in the session for the creator of the group, so that the user can be redirected.
session[:pg_id] = @programming_group.id

View File

@ -32,6 +32,7 @@ class Exercise < ApplicationRecord
has_many :external_users, source: :contributor, source_type: 'ExternalUser', through: :submissions
has_many :internal_users, source: :contributor, source_type: 'InternalUser', through: :submissions
has_many :programming_groups
has_many :pair_programming_waiting_users
scope :with_submissions, -> { where('id IN (SELECT exercise_id FROM submissions)') }

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
class PairProgrammingWaitingUser < ApplicationRecord
include Creation
belongs_to :exercise
belongs_to :programming_group, optional: true
enum status: {
waiting: 0,
joined_pg: 1,
disconnected: 2,
worked_alone: 3,
created_pg: 4,
}, _prefix: true
validates :user_id, uniqueness: {scope: %i[exercise_id user_type]}
validates :programming_group_id, presence: true, if: -> { status_joined_pg? || status_created_pg? }
after_save :capture_event
def capture_event
Event.create(category: 'pp_matching', user:, exercise:, data: status.to_s)
end
end

View File

@ -12,6 +12,7 @@ class ProgrammingGroup < ApplicationRecord
has_many :events
has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor'
has_many :pair_programming_exercise_feedbacks
has_many :pair_programming_waiting_users
belongs_to :exercise
validate :min_group_size

View File

@ -25,6 +25,7 @@ class User < ApplicationRecord
has_many :events
has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor'
has_many :pair_programming_exercise_feedbacks
has_many :pair_programming_waiting_users
has_one :codeharbor_link, dependent: :destroy
accepts_nested_attributes_for :user_proxy_exercise_exercises

View File

@ -1,8 +1,16 @@
= form_for(@programming_group, url: exercise_programming_groups_path) do |f|
= render('shared/form_errors', object: @programming_group)
.mb-3
= f.label(:programming_partner_ids, class: 'form-label')
= f.text_field(:programming_partner_ids, class: 'form-control', required: true, value: (@programming_group.programming_partner_ids - [current_user.id_with_type]).join(', '))
/.help-block.form-text = t('.hints.programming_partner_ids')
.actions.mb-0
= render('shared/submit_button', f: f, object: @programming_group)
.form-group
.row
.col-md-6
= f.label(:programming_partner_ids, class: 'form-label')
.row
.col-md-6
.input-group.mb-3
= f.text_field(:programming_partner_ids, class: 'form-control', required: true, value: (@programming_group.programming_partner_ids - [current_user.id_with_type]).join(', '))
= render('shared/submit_button', f: f, object: @programming_group)
/.help-block.form-text = t('.hints.programming_partner_ids')
.col-md-6
.join_programming_pair.button.btn.btn-primary.d-none.d-md-block
i.fa-solid.fa-circle-notch.fa-spin.d-none
= t('programming_groups.new.find_programming_partner')

View File

@ -1,30 +1,38 @@
h1 = t('programming_groups.new.create_programming_pair')
#matching.row data-exercise-id=@exercise.id
.col-md-6
p
h1.d-inline-block = t('programming_groups.new.create_programming_pair')
.btn.btn-success.float-end data-bs-toggle= 'modal' data-bs-target= '#modal-info-pair-programming'
i.fa-solid.fa-circle-info
= t('programming_groups.new.pair_programming_info')
#matching data-exercise-id=@exercise.id.to_s
.row
.col-12.mt-2.mb-4
p = t('programming_groups.new.info_work_together', exercise_title: @exercise.title)
.text-body-tertiary == t('exercises.implement.pair_programming_feedback', url: "https://etherpad.xopic.de/p/openHPI_PairProgrammingFeedback?userName=#{CGI.escape(current_user.displayname)}")
.row
.col-md-6
h5 = t('programming_groups.new.work_with_a_friend')
p = t('programming_groups.new.enter_partner_id', exercise_title: @exercise.title)
=> t('programming_groups.new.own_user_id')
b
= current_user.id_with_type
b = current_user.id_with_type
.d-md-none
= render('form')
button.btn.btn-success data-bs-toggle= 'modal' data-bs-target= '#modal-info-pair-programming'
i.fa-solid.fa-circle-info
= t('programming_groups.new.pair_programming_info')
.col-md-6
h5 = t('programming_groups.new.find_partner_title')
p = t('programming_groups.new.find_partner_description')
p.mt-4
= t('programming_groups.new.enter_partner_id', exercise_title: @exercise.title)
= render('form')
div.mt-4
== t('programming_groups.new.work_alone', path: implement_exercise_path(@exercise))
.col-md-6
h5 = t('programming_groups.new.find_partner_title')
p
= t('programming_groups.new.find_partner_description')
- if !browser.safari?
iframe name="embed_readwrite" src="https://etherpad.xopic.de/p/find_programming_group_for_exercise_#{@exercise.id}?userName=#{CGI.escape(current_user.displayname)}&showControls=false&showChat=false&showLineNumbers=true&useMonospaceFont=false" width="100%" height="300" style='border: 1px solid black;'
- else
== t('programming_groups.new.safari_not_supported', url: "https://etherpad.xopic.de/p/find_programming_group_for_exercise_#{@exercise.id}?userName=#{CGI.escape(current_user.displayname)}&showControls=false&showChat=false&showLineNumbers=true&useMonospaceFont=false")
.join_programming_pair.button.btn.btn-primary.d-md-none.mb-3
i.fa-solid.fa-circle-notch.fa-spin.d-none
= t('programming_groups.new.find_programming_partner')
.row
.col-12.d-none.d-md-block
= render('form')
.row
.col-12
h5 = t('programming_groups.new.work_alone')
== t('programming_groups.new.work_alone_description', path: implement_exercise_path(@exercise))
= render('shared/modal', classes: 'modal-lg', id: 'modal-info-pair-programming', template: 'programming_groups/_info_pair_programming', title: t('programming_groups.new.pair_programming_info'))

View File

@ -233,8 +233,8 @@ de:
one: Feedback
other: Feedbacks
programming_group:
one: Programmiergruppe
other: Programmiergruppen
one: Programmierpaar
other: Programmierpaare
programming_group_membership:
one: Programmiergruppenmitgliedschaft
other: Programmiergruppenmitgliedschaften
@ -598,15 +598,18 @@ de:
close: Schließen
create_programming_pair: Programmierpaar erstellen
dont_show_modal_again: "Auf diesem Gerät nicht mehr anzeigen"
enter_partner_id: "Bitte gib hier die Nutzer-ID der Person ein, mit der du zusammen die Aufgabe '%{exercise_title}' lösen möchtest. Beachte jedoch, dass anschließend keiner die Zusammenarbeit beenden kann. Dein:e Teampartner:in kann sehen, was du in dieser Aufgabe schreibst und umgekehrt. Für die nächste Aufgabe kannst du dich erneuert entscheiden, ob und mit wem du zusammen arbeiten möchtest."
enter_partner_id: "Kennst du eine Person in dem Kurs, mit der du gemeinsam die Aufgabe lösen möchtest? Dann gib hier die Nutzer-ID dieser Person ein."
find_partner_title: "Finde eine:n Programmierpartner:in für die Aufgabe"
find_partner_description: "Kopiere eine andere Nutzer-ID aus der Liste unten und lösche sie anschließend. Wenn noch keine Nutzer-IDs in der Liste vorhanden sind, füge deine Nutzer-ID hinzu und warte, bis ein andere:r Nutzer:in ein Programmierpaar mit dir erstellt. Sobald dich eine Partner:in zu einer Gruppe eingeladen hast, wirst Du automatisch weitergeleitet, um gemeinsam an dieser Aufgabe arbeiten zu können. Wenn du allein arbeiten möchtest, lösche bitte deine Nutzer-ID aus der Liste, falls du sie hinzugefügt hast."
find_partner_description: "Wenn du keine Person aus dem Kurs kennst, hast du die Möglichkeit mit einer anderen Person gepaart zu werden. Du wirst dann zur Aufgabe weitergeleitet, sobald eine andere Person ebenfalls diese Aufgabe im Team lösen möchte."
info_pair_programming: "Pair Programming (Programmieren in Paaren) ist eine Methode, bei der zwei Personen gemeinsam programmieren. Dabei übernehmen sie <i>abwechselnd</i> zwei verschiedene Rollen: Den <i>Driver</i>, der den Code schreibt und sich auf die Details fokussiert und den <i>Navigator</i>, der Tippfehler korrigiert, die Aufgabenstellung im Blick behält und Verbesserungsideen vorschlägt. Kommunikation miteinander ist von entscheidender Bedeutung für erfolgreiches Pair Programming."
info_study: "Im Rahmen meiner Masterarbeit möchte ich das Anwenden von Pair Programming in diesem MOOC analysieren. Ich würde mich daher sehr darüber freuen, wenn ihr gemeinsam mit einer anderen Person die Aufgaben bearbeitet und mir anschließend euer Feedback in den Umfragen mitteilt. Bitte beachtet, dass es sich bei dieser Funktion um eine Beta-Version handelt, die bisher noch nicht für alle Nutzer:innen freigeschaltet wurde."
info_work_together: "Du hast die Möglichkeit, die Aufgabe '%{exercise_title}' zusammen mit einer anderen Person zu lösen. Dein:e Teampartner:in kann sehen, was du in dieser Aufgabe schreibst und umgekehrt. Beachte dabei, dass anschließend keiner die Zusammenarbeit beenden kann. Für die nächste Aufgabe kannst du dich erneuert entscheiden, ob und mit wem du zusammen arbeiten möchtest."
find_programming_partner: Programmierpartner:in finden
own_user_id: "Deine Nutzer-ID:"
pair_programming_info: Pair Programming Info
safari_not_supported: "Safari unterstützt nicht das Einbinden der Seite mit den Nutzer-IDs. <a target='_blank', style='display: contents;' href=%{url}>Bitte klicke hier, um die Liste der Nutzer-IDs zu öffnen.</a>"
work_alone: "Du kannst dich einmalig dafür entscheiden, die Aufgabe alleine zu bearbeiten. Anschließend kannst du jedoch nicht mehr in die Partnerarbeit für diese Aufgabe wechseln. <a href=%{path}>Klicke hier, um die Aufgabe im Einzelmodus zu starten.</a>"
work_alone: "Alleine arbeiten"
work_alone_description: "Du kannst dich einmalig dafür entscheiden, die Aufgabe alleine zu bearbeiten. Anschließend kannst du jedoch nicht mehr in die Partnerarbeit für diese Aufgabe wechseln. <br> <a href=%{path}>Klicke hier, um die Aufgabe im Einzelmodus zu starten.</a>"
work_with_a_friend: "Mit einem/einer Freund:in zusammenarbeiten"
implement:
info_disconnected: Ihre Verbindung zum Server wurde unterbrochen. Bitte überprüfen Sie Ihre Internetverbindung und laden Sie die Seite erneut.
external_users:

View File

@ -233,8 +233,8 @@ en:
one: Feedback
other: Feedbacks
programming_group:
one: Programming Group
other: Programming Groups
one: Programming Pair
other: Programming Pairs
programming_group_membership:
one: Programming Group Membership
other: Programming Group Memberships
@ -598,15 +598,18 @@ en:
close: Close
create_programming_pair: Create Programming Pair
dont_show_modal_again: "Don't display on this device anymore"
enter_partner_id: "Please enter the user ID from the practice partner with whom you want to solve the exercise '%{exercise_title}'. However, note that no one can leave the pair afterward. Hence, your team partner can see what you write in this exercise and vice versa. For the next exercise, you can decide again whether and with whom you want to work together."
enter_partner_id: "Do you know a person in the course with whom you would like to solve the task together? Then enter that person's user ID here."
find_partner_title: "Find a programming partner for the exercise"
find_partner_description: "Copy another user ID from the list below and delete it afterward. If there are no user IDs on the list yet, add your user ID and wait for another user to create a programming group with you. Once a partner invited you to a group, you'll be redirected automatically to start working on this exercise collaboratively. If you decide to work alone, please delete your user ID from the list if you added it before."
find_partner_description: "If you don't know a person from the course, you have the possibility to be paired with another person. Then, you will be redirected to the task as soon as another person also wants to solve this task in a team."
info_pair_programming: "Pair Programming is a method where two people program together. They alternate between two distinct roles: the <i>Driver</i>, responsible for writing the code and focusing on the details, and the <i>Navigator</i>, tasked with correcting typos, overseeing the task's progress, and offering suggestions for improvement. Effective communication in the pair is crucial for the success of pair programming."
info_study: "As part of my master's thesis, I would like to analyze the use of pair programming in this MOOC. Therefore, I would be very happy if you work on the exercises together with another person and then give me your feedback in the surveys. Please note that this feature is a beta version that has not yet been activated for all users."
info_work_together: "You have the possibility to solve the task '%{exercise_title}' together with another person. Your team partner can see what you write in this task and vice versa. Note that no one can stop the collaboration afterwards. For the next task you can decide again if and with whom you want to work together."
find_programming_partner: Find Programming Partner
own_user_id: "Your user ID:"
pair_programming_info: Pair Programming Info
safari_not_supported: "Safari does not support embedding the user IDs page. <a target='_blank', style='display: contents;' href=%{url}>Please click here to open the list of user IDs.</a>"
work_alone: "You can choose once to work on the exercise alone. Afterward, however, you will not be able to switch to work in a pair for this exercise. <a href=%{path}>Click here to get to the exercise in single mode.</a>"
work_alone: Work Alone
work_alone_description: "You can choose once to work on the exercise alone. Afterward, however, you will not be able to switch to work in a pair for this exercise. <br> <a href=%{path}>Click here to get to the exercise in single mode.</a>"
work_with_a_friend: "Work with a friend"
implement:
info_disconnected: You are disconnected from the server. Please check your internet connection and reload the page.
external_users:

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreatePairProgrammingWaitingUsers < ActiveRecord::Migration[7.0]
def change
create_table :pair_programming_waiting_users, id: :uuid do |t|
t.references :user, index: true, null: false, polymorphic: true
t.references :exercise, index: true, null: false, foreign_key: true
t.references :programming_group, index: true, null: true, foreign_key: true
t.integer :status, limit: 1, null: false, comment: 'Used as enum in Rails'
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_12_162208) do
ActiveRecord::Schema[7.0].define(version: 2023_09_20_094122) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
enable_extension "pgcrypto"
@ -401,6 +401,19 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_12_162208) do
t.index ["user_type", "user_id"], name: "index_pair_programming_exercise_feedbacks_on_user"
end
create_table "pair_programming_waiting_users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "user_type", null: false
t.bigint "user_id", null: false
t.bigint "exercise_id", null: false
t.bigint "programming_group_id"
t.integer "status", limit: 2, null: false, comment: "Used as enum in Rails"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["exercise_id"], name: "index_pair_programming_waiting_users_on_exercise_id"
t.index ["programming_group_id"], name: "index_pair_programming_waiting_users_on_programming_group_id"
t.index ["user_type", "user_id"], name: "index_pair_programming_waiting_users_on_user"
end
create_table "programming_group_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.bigint "programming_group_id", null: false
t.string "user_type", null: false
@ -666,6 +679,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_12_162208) do
add_foreign_key "pair_programming_exercise_feedbacks", "programming_groups"
add_foreign_key "pair_programming_exercise_feedbacks", "study_groups"
add_foreign_key "pair_programming_exercise_feedbacks", "submissions"
add_foreign_key "pair_programming_waiting_users", "exercises"
add_foreign_key "pair_programming_waiting_users", "programming_groups"
add_foreign_key "programming_group_memberships", "programming_groups"
add_foreign_key "programming_groups", "exercises"
add_foreign_key "remote_evaluation_mappings", "study_groups"