From 9f837412c7058e60c4175d9e43d0b4957d75a01d Mon Sep 17 00:00:00 2001 From: Kira Grammel <49536988+kiragrammel@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:07:10 +0200 Subject: [PATCH] Add waiting room to create programming groups (#1919) Co-authored-by: Sebastian Serth --- .../channels/pg_matching_channel.js | 14 ++++- app/assets/javascripts/programming_groups.js | 12 ++++ app/channels/pg_matching_channel.rb | 23 ++++++++ app/controllers/exercises_controller.rb | 5 +- .../programming_groups_controller.rb | 3 + app/models/exercise.rb | 1 + app/models/pair_programming_waiting_user.rb | 25 +++++++++ app/models/programming_group.rb | 1 + app/models/user.rb | 1 + app/views/programming_groups/_form.html.slim | 20 +++++-- app/views/programming_groups/new.html.slim | 56 +++++++++++-------- config/locales/de.yml | 15 +++-- config/locales/en.yml | 15 +++-- ...2_create_pair_programming_waiting_users.rb | 14 +++++ db/schema.rb | 17 +++++- 15 files changed, 174 insertions(+), 48 deletions(-) create mode 100644 app/models/pair_programming_waiting_user.rb create mode 100644 db/migrate/20230920094122_create_pair_programming_waiting_users.rb diff --git a/app/assets/javascripts/channels/pg_matching_channel.js b/app/assets/javascripts/channels/pg_matching_channel.js index cc393058..f07fe4fd 100644 --- a/app/assets/javascripts/channels/pg_matching_channel.js +++ b/app/assets/javascripts/channels/pg_matching_channel.js @@ -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'); + } }); } }); diff --git a/app/assets/javascripts/programming_groups.js b/app/assets/javascripts/programming_groups.js index 04df7149..e49ce505 100644 --- a/app/assets/javascripts/programming_groups.js +++ b/app/assets/javascripts/programming_groups.js @@ -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); + }); + } }); diff --git a/app/channels/pg_matching_channel.rb b/app/channels/pg_matching_channel.rb index cca4308a..ceb28432 100644 --- a/app/channels/pg_matching_channel.rb +++ b/app/channels/pg_matching_channel.rb @@ -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? diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 37a46b92..0db1e19f 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -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) diff --git a/app/controllers/programming_groups_controller.rb b/app/controllers/programming_groups_controller.rb index 968ba4d9..a18d6ce8 100644 --- a/app/controllers/programming_groups_controller.rb +++ b/app/controllers/programming_groups_controller.rb @@ -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 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 9f3ec6aa..136220fc 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -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)') } diff --git a/app/models/pair_programming_waiting_user.rb b/app/models/pair_programming_waiting_user.rb new file mode 100644 index 00000000..27ad72e5 --- /dev/null +++ b/app/models/pair_programming_waiting_user.rb @@ -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 diff --git a/app/models/programming_group.rb b/app/models/programming_group.rb index fd036817..e4d13ae6 100644 --- a/app/models/programming_group.rb +++ b/app/models/programming_group.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 253e5137..9993e024 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/programming_groups/_form.html.slim b/app/views/programming_groups/_form.html.slim index 03732a07..4b240d77 100644 --- a/app/views/programming_groups/_form.html.slim +++ b/app/views/programming_groups/_form.html.slim @@ -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') diff --git a/app/views/programming_groups/new.html.slim b/app/views/programming_groups/new.html.slim index e3aaee62..3a28a6b8 100644 --- a/app/views/programming_groups/new.html.slim +++ b/app/views/programming_groups/new.html.slim @@ -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')) diff --git a/config/locales/de.yml b/config/locales/de.yml index 913f7e3c..0547ee81 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -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 abwechselnd zwei verschiedene Rollen: Den Driver, der den Code schreibt und sich auf die Details fokussiert und den Navigator, 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. Bitte klicke hier, um die Liste der Nutzer-IDs zu öffnen." - 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. Klicke hier, um die Aufgabe im Einzelmodus zu starten." + 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.
Klicke hier, um die Aufgabe im Einzelmodus zu starten." + 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: diff --git a/config/locales/en.yml b/config/locales/en.yml index a88cb58f..229f6be5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 Driver, responsible for writing the code and focusing on the details, and the Navigator, 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. Please click here to open the list of user IDs." - 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. Click here to get to the exercise in single mode." + 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.
Click here to get to the exercise in single mode." + 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: diff --git a/db/migrate/20230920094122_create_pair_programming_waiting_users.rb b/db/migrate/20230920094122_create_pair_programming_waiting_users.rb new file mode 100644 index 00000000..25bba6ee --- /dev/null +++ b/db/migrate/20230920094122_create_pair_programming_waiting_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index f676b90e..e16f8586 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[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"