From 79422225a87237dc1d8fa74d5358bc635e8949b2 Mon Sep 17 00:00:00 2001 From: kiragrammel Date: Mon, 4 Sep 2023 23:57:30 +0200 Subject: [PATCH] Add events for pair programming study --- app/assets/javascripts/editor/editor.js.erb | 14 +++ app/channels/application_cable/connection.rb | 1 + app/channels/synchronized_editor_channel.rb | 5 +- app/controllers/events_controller.rb | 18 ++-- app/controllers/exercises_controller.rb | 2 + .../programming_groups_controller.rb | 5 + app/models/code_ocean/file.rb | 1 + app/models/event.rb | 15 ++- app/models/event/synchronized_editor.rb | 92 +++++++++++++++++++ app/models/programming_group.rb | 2 + app/models/study_group.rb | 2 + app/views/exercises/implement.html.slim | 11 ++- ...d_contributor_and_study_group_to_events.rb | 8 ++ ...80123_create_events_synchronized_editor.rb | 27 ++++++ db/schema.rb | 36 +++++++- 15 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 app/models/event/synchronized_editor.rb create mode 100644 db/migrate/20230904174803_add_contributor_and_study_group_to_events.rb create mode 100644 db/migrate/20230904180123_create_events_synchronized_editor.rb diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb index 4caef636..eeec6d51 100644 --- a/app/assets/javascripts/editor/editor.js.erb +++ b/app/assets/javascripts/editor/editor.js.erb @@ -382,6 +382,20 @@ var CodeOceanEditor = { $(document).on('click', '#results a', this.showOutput.bind(this)); $(document).on('keydown', this.handleKeyPress.bind(this)); $(document).on('theme:change:ace', this.handleAceThemeChangeEvent.bind(this)); + $('#start_chat').on('click', function(event) { + this.createEventHandler('pp_start_chat', null)(event) + // Allow to open the new tab even in Safari. + // See: https://stackoverflow.com/a/70463940 + setTimeout(() => { + var pop_up_window = window.open($('#start_chat').data('url'), '_blank'); + if (pop_up_window) { + pop_up_window.onerror = function (message) { + $.flash.danger({text: message}); + this.sendError(message, null); + }; + } + }) + }.bind(this)); this.initializeFileTreeButtons(); this.initializeWorkspaceButtons(); this.initializeRequestForComments() diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index e2a6d251..f6900259 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -24,6 +24,7 @@ module ApplicationCable def find_verified_user # Finding the current_user is similar to the code used in application_controller.rb#current_user current_user = ExternalUser.find_by(id: session[:external_user_id]) || InternalUser.find_by(id: session[:user_id]) + current_user&.store_current_study_group_id(session[:study_group_id]) current_user || reject_unauthorized_connection end diff --git a/app/channels/synchronized_editor_channel.rb b/app/channels/synchronized_editor_channel.rb index 3b40919f..d6a5f90c 100644 --- a/app/channels/synchronized_editor_channel.rb +++ b/app/channels/synchronized_editor_channel.rb @@ -22,7 +22,10 @@ class SynchronizedEditorChannel < ApplicationCable::Channel end def send_changes(message) - ActionCable.server.broadcast(specific_channel, message['delta_with_user_id']) + change = message['delta_with_user_id'].deep_symbolize_keys + + Event::SynchronizedEditor.create_for_editor_change(change, current_user, programming_group) + ActionCable.server.broadcast(specific_channel, change) end def send_hello diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 292ee0be..16ba0d63 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true class EventsController < ApplicationController - def authorize! - authorize(@event || @events) - end - private :authorize! + before_action :require_user! def create @event = Event.new(event_params) @@ -20,12 +17,21 @@ class EventsController < ApplicationController end end + private + + def authorize! + authorize(@event || @events) + end + def event_params # The file ID processed here is the context of the exercise (template), # not in the context of the submission! params[:event] &.permit(:category, :data, :exercise_id, :file_id) - &.merge(user: current_user) + &.merge(user: current_user, programming_group:, study_group_id: current_user.current_study_group_id) + end + + def programming_group + current_contributor if current_contributor.programming_group? end - private :event_params end diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index dd7642f2..31c45d2e 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -315,6 +315,8 @@ 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? + Event.create(category: 'pp_work_alone', user: current_user, exercise: @exercise, data: nil, file_id: nil) 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 7abdae96..7d5de258 100644 --- a/app/controllers/programming_groups_controller.rb +++ b/app/controllers/programming_groups_controller.rb @@ -7,6 +7,7 @@ class ProgrammingGroupsController < ApplicationController before_action :set_exercise_and_authorize def new + Event.create(category: 'page_visit', user: current_user, exercise: @exercise, data: 'programming_groups_new', file_id: nil) if current_user.submissions.where(exercise: @exercise, study_group_id: current_user.current_study_group_id).any? # A learner has worked on this exercise **alone** in the context of the **current study group**, so we redirect them to their progress. redirect_to_exercise @@ -36,6 +37,10 @@ class ProgrammingGroupsController < ApplicationController @programming_group.add(current_user) end + unless @programming_group.valid? + Event.create(category: 'pp_invalid_partners', user: current_user, exercise: @exercise, data: programming_group_params[:programming_partner_ids], file_id: nil) + end + create_and_respond(object: @programming_group, path: proc { implement_exercise_path(@exercise) }) do session[:pg_id] = @programming_group.id nil diff --git a/app/models/code_ocean/file.rb b/app/models/code_ocean/file.rb index e97f7785..008555cc 100644 --- a/app/models/code_ocean/file.rb +++ b/app/models/code_ocean/file.rb @@ -32,6 +32,7 @@ module CodeOcean has_many :files, class_name: 'CodeOcean::File' has_many :testruns has_many :comments + has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor' alias descendants files mount_uploader :native_file, FileUploader diff --git a/app/models/event.rb b/app/models/event.rb index e4798059..ef208e0d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -3,8 +3,19 @@ class Event < ApplicationRecord include Creation belongs_to :exercise - belongs_to :file, class_name: 'CodeOcean::File' + belongs_to :file, class_name: 'CodeOcean::File', optional: true + belongs_to :study_group, optional: true + belongs_to :programming_group, optional: true validates :category, presence: true - validates :data, presence: true + + # We temporary allow an event to be stored without data. + # This is useful if the category (together with the user and exercise) is already enough. + validates :data, presence: true, if: -> { %w[pp_start_chat pp_invalid_partners pp_work_alone].exclude?(category) } + + before_validation :data_presence + + def data_presence + self.data = data.presence + end end diff --git a/app/models/event/synchronized_editor.rb b/app/models/event/synchronized_editor.rb new file mode 100644 index 00000000..ce41a0be --- /dev/null +++ b/app/models/event/synchronized_editor.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class Event::SynchronizedEditor < ApplicationRecord + self.table_name = 'events_synchronized_editor' + + include Creation + + belongs_to :programming_group + belongs_to :study_group + belongs_to :file, class_name: 'CodeOcean::File' + + enum command: { + editor_change: 0, + connection_change: 1, + hello: 2, + ### TODO: Kira's commands + }, _prefix: true + + enum status: { + connected: 0, + disconnected: 1, + ### TODO: connected, disconnected ... + }, _prefix: true + + enum action: { + insertText: 0, + insertLines: 1, + removeText: 2, + removeLines: 3, + changeFold: 4, + removeFold: 5, + ### TODO: AceEditor Actions: insertText, insertLines, removeText, removesLines, ... + }, _prefix: true + + validates :status, presence: true, if: -> { command_connection_change? } + validates :action, presence: true, if: -> { command_editor_change? } + validates :range_start_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? } + validates :range_start_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? } + validates :range_end_row, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? } + validates :range_end_column, numericality: {only_integer: true, greater_than_or_equal_to: 0}, if: -> { command_editor_change? } + validates :nl, inclusion: {in: %W[\n \r\n]}, if: -> { action_removeLines? } + + validate :either_lines_or_text + + def self.create_for_editor_change(event, user, programming_group) + event_copy = event.deep_dup + file = event_copy.delete(:active_file) + delta = event_copy.delete(:delta)[:data] + range = delta.delete(:range) + + create!( + user:, + programming_group:, + study_group_id: user.current_study_group_id, + command: event_copy.delete(:command), + action: delta.delete(:action), + file_id: file[:id], + range_start_row: range[:start][:row], + range_start_column: range[:start][:column], + range_end_row: range[:end][:row], + range_end_column: range[:end][:column], + text: delta.delete(:text), + nl: delta.delete(:nl), + lines: delta.delete(:lines), + data: data_attribute(event_copy, delta) + ) + end + + def self.data_attribute(event, delta) + event[:delta] = {data: delta} if delta.present? + event.presence if event.present? # TODO: As of now, we are storing the `current_user_id` most of the times. Intended? + end + private_class_method :data_attribute + + private + + def strip_strings + # trim whitespace from beginning and end of string attributes + # except the `text` and `nl` of Event::SynchronizedEditor + attribute_names.without('text', 'nl').each do |name| + if send(name.to_sym).respond_to?(:strip) + send("#{name}=".to_sym, send(name).strip) + end + end + end + + def either_lines_or_text + if [lines, text].count(&:present?) > 1 + errors.add(:text, "can't be present if lines is also present") + end + end +end diff --git a/app/models/programming_group.rb b/app/models/programming_group.rb index 86ee9eb7..d87cc9d9 100644 --- a/app/models/programming_group.rb +++ b/app/models/programming_group.rb @@ -9,6 +9,8 @@ class ProgrammingGroup < ApplicationRecord has_many :internal_users, through: :programming_group_memberships, source_type: 'InternalUser', source: :user has_many :testruns, through: :submissions has_many :runners, as: :contributor, dependent: :destroy + has_many :events + has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor' belongs_to :exercise validate :min_group_size diff --git a/app/models/study_group.rb b/app/models/study_group.rb index dac8c1f5..1fb22813 100644 --- a/app/models/study_group.rb +++ b/app/models/study_group.rb @@ -9,6 +9,8 @@ class StudyGroup < ApplicationRecord has_many :subscriptions, dependent: :nullify has_many :authentication_tokens, dependent: :nullify has_many :lti_parameters, dependent: :delete_all + has_many :events + has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor' belongs_to :consumer def users diff --git a/app/views/exercises/implement.html.slim b/app/views/exercises/implement.html.slim index 462be583..584fe8c5 100644 --- a/app/views/exercises/implement.html.slim +++ b/app/views/exercises/implement.html.slim @@ -9,12 +9,13 @@ div.col-3.float-end span.badge.rounded-pill.bg-primary.float-end.mt-2.score - if current_contributor.programming_group? - a.btn.btn-sm.btn-primary.me-3.mt-1(href='https://jitsi.fem.tu-ilmenau.de/openHPI_ProgrammingGroup#{current_contributor.id}', target='_blank') - i.fa-solid.fa-video - span = t('exercises.editor.start_video') + button.btn.btn-sm.btn-primary.me-3.mt-1#start_chat data= {url: "https://jitsi.fem.tu-ilmenau.de/openHPI_ProgrammingGroup#{current_contributor.id}"} + i.fa-solid.fa-video - div.small.text-body-tertiary.mt-1 - == t('exercises.implement.external_privacy_policy', url:'https://www.tu-ilmenau.de/datenschutz') + span = t('exercises.editor.start_video') + + div.small.text-body-tertiary.mt-1 + == t('exercises.implement.external_privacy_policy', url:'https://www.tu-ilmenau.de/datenschutz') h1 id="exercise-headline" i id="description-symbol" class=(@embed_options[:collapse_exercise_description] ? 'fa-solid fa-chevron-right' : 'fa-solid fa-chevron-down') diff --git a/db/migrate/20230904174803_add_contributor_and_study_group_to_events.rb b/db/migrate/20230904174803_add_contributor_and_study_group_to_events.rb new file mode 100644 index 00000000..d2ac4fe0 --- /dev/null +++ b/db/migrate/20230904174803_add_contributor_and_study_group_to_events.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddContributorAndStudyGroupToEvents < ActiveRecord::Migration[7.0] + def change + add_reference :events, :programming_group, index: true, null: true, foreign_key: true + add_reference :events, :study_group, index: true, null: true, foreign_key: true + end +end diff --git a/db/migrate/20230904180123_create_events_synchronized_editor.rb b/db/migrate/20230904180123_create_events_synchronized_editor.rb new file mode 100644 index 00000000..fc20dae6 --- /dev/null +++ b/db/migrate/20230904180123_create_events_synchronized_editor.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateEventsSynchronizedEditor < ActiveRecord::Migration[7.0] + def change + create_table :events_synchronized_editor, id: :uuid do |t| + t.references :programming_group, index: true, null: false, foreign_key: true + t.references :study_group, index: true, null: false, foreign_key: true + t.references :user, index: true, null: false, polymorphic: true + t.integer :command, limit: 1, null: false, default: 0, comment: 'Used as enum in Rails' + t.integer :status, limit: 1, null: true, comment: 'Used as enum in Rails' + + # The following attributes are only stored for delta objects + t.references :file, index: true, null: true, foreign_key: true + t.integer :action, limit: 1, null: true, comment: 'Used as enum in Rails' + t.integer :range_start_row, null: true + t.integer :range_start_column, null: true + t.integer :range_end_row, null: true + t.integer :range_end_column, null: true + t.text :text, null: true + t.text :lines, null: true, array: true + t.string :nl, limit: 2, null: true, comment: 'Identifies the line break type (i.e., \r\n or \n)' + t.jsonb :data, null: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index cec24bfa..5955d25a 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_08_21_063101) do +ActiveRecord::Schema[7.0].define(version: 2023_09_04_180123) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" enable_extension "pgcrypto" @@ -143,6 +143,35 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_21_063101) do t.integer "file_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "programming_group_id" + t.bigint "study_group_id" + t.index ["programming_group_id"], name: "index_events_on_programming_group_id" + t.index ["study_group_id"], name: "index_events_on_study_group_id" + end + + create_table "events_synchronized_editor", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "programming_group_id", null: false + t.bigint "study_group_id", null: false + t.string "user_type", null: false + t.bigint "user_id", null: false + t.integer "command", limit: 2, default: 0, null: false, comment: "Used as enum in Rails" + t.integer "status", limit: 2, comment: "Used as enum in Rails" + t.bigint "file_id" + t.integer "action", limit: 2, comment: "Used as enum in Rails" + t.integer "range_start_row" + t.integer "range_start_column" + t.integer "range_end_row" + t.integer "range_end_column" + t.text "text" + t.text "lines", array: true + t.string "nl", limit: 2, comment: "Identifies the line break type (i.e., \\r\\n or \\n)" + t.jsonb "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["file_id"], name: "index_events_synchronized_editor_on_file_id" + t.index ["programming_group_id"], name: "index_events_synchronized_editor_on_programming_group_id" + t.index ["study_group_id"], name: "index_events_synchronized_editor_on_study_group_id" + t.index ["user_type", "user_id"], name: "index_events_synchronized_editor_on_user" end create_table "execution_environments", id: :serial, force: :cascade do |t| @@ -603,6 +632,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_21_063101) do add_foreign_key "community_solution_contributions", "study_groups" add_foreign_key "community_solution_locks", "community_solutions" add_foreign_key "community_solutions", "exercises" + add_foreign_key "events", "programming_groups" + add_foreign_key "events", "study_groups" + add_foreign_key "events_synchronized_editor", "files" + add_foreign_key "events_synchronized_editor", "programming_groups" + add_foreign_key "events_synchronized_editor", "study_groups" add_foreign_key "exercise_tips", "exercise_tips", column: "parent_exercise_tip_id" add_foreign_key "exercise_tips", "exercises" add_foreign_key "exercise_tips", "tips"