diff --git a/.config/commands/utils.justfile b/.config/commands/utils.justfile new file mode 100644 index 000000000..1e5e29b9d --- /dev/null +++ b/.config/commands/utils.justfile @@ -0,0 +1,71 @@ +# Prints this help message +[private] +help: + @just --list --justfile {{source_file()}} + +# Generates entity-relationship diagrams (ERD) of the database +erd: + #!/usr/bin/env bash + + # Make sure the mampf dev container is running + cd {{justfile_directory()}}/docker/development/ + if [ -z "$(docker compose ps --services --filter 'status=running' | grep mampf)" ]; then + echo "The mampf dev container is not running. Please start it first (use 'just docker')." + exit 1 + fi + + mkdir -p {{justfile_directory()}}/tmp/erd/ + + # ▶ Generate ERDs + # Customize it with options from here: https://voormedia.github.io/rails-erd/customise.html + # Also see the output from: 'bundle exec erd --help' (inside the dev container) + + # Ignore some tables + ignored_thredded="Thredded::Post,Thredded::UserPostNotification,Thredded::PrivateUser,Thredded::UserPrivateTopicReadState,Thredded::PrivateTopic,Thredded::MessageboardUser,Thredded::PrivatePost,Thredded:UserDetail,Thredded::MessageboardGroup,Thredded::Messageboard,Thredded::Category,Thredded::TopicCategory,Thredded::Topic,Thredded::UserTopicReadState,Thredded::UserTopicFollow,Thredded::NotificationsForFollowedTopics,Thredded::MessageboardNotificationsForFollowedTopics,Thredded::UserPreference,Thredded::UserMessageboardPreference,Thredded::NotificationsForPrivateTopics,Thredded::PostModerationRecord,Thredded::UserDetail" + ignored_translation="Mobility::Backends::ActiveRecord::Table::Translation,Subject::Translation,Program::Translation,Division::Translation" + ignored_commontator="Commontable,Votable,Subscriber,Creator" + other_ignored="ActionMailbox::Record,ActionText::Record,ActiveStorage::Record,Sluggable,FriendlyId::Slug,ApplicationRecord,InteractionsRecord" + exclude_default="${ignored_thredded},${ignored_translation},${ignored_commontator},${other_ignored}" + + # 🌟 Overview with attributes (warnings will be printed only here) + docker compose exec -it mampf rake erd \ + title=false filename=/usr/src/app/tmp/erd/mampf-erd-overview-with-attributes \ + inheritance=false polymorphism=true indirect=false attributes=content \ + exclude="${exclude_default}" + + # 🌟 Generic Overview + docker compose exec -it mampf rake erd warn=false \ + title=false filename=/usr/src/app/tmp/erd/mampf-erd-overview \ + inheritance=false polymorphism=true indirect=false attributes=false \ + exclude="${exclude_default}" + + # 🌟 Vouchers + docker compose exec -it mampf rake erd warn=false \ + title="Vouchers" filename=/usr/src/app/tmp/erd/mampf-erd-vouchers \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Teachable,Editable" \ + only="User,Claim,Voucher,Redemption,Lecture,Tutorial,Talk" + + # 🌟 Tutorials + docker compose exec -it mampf rake erd warn=false \ + title="Tutorials" filename=/usr/src/app/tmp/erd/mampf-erd-tutorials \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable,Teachable" \ + only="User,Lecture,Tutorial,Submission,Assignment,TutorTutorialJoin,UserSubmissionJoin" + + # 🌟 Courses + docker compose exec -it mampf rake erd warn=false \ + title="Courses" filename=/usr/src/app/tmp/erd/mampf-erd-courses \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable" \ + only="Subject,Program,Division,DivisionCourseJoin,Course,Lecture,CourseSelfJoin,Lesson" + + # 🌟 Lectures + docker compose exec -it mampf rake erd warn=false \ + title="Lectures" filename=/usr/src/app/tmp/erd/mampf-erd-lectures \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable,Teachable" \ + only="Lecture,Lesson,Chapter,Section,Item,LessonSectionJoin,Term" + + echo "📂 Diagrams are ready for you in the folder {{justfile_directory()}}/tmp/erd/" + echo "🔀 For the meanings of the arrows, refer to https://voormedia.github.io/rails-erd/gallery.html#notations" diff --git a/.justfile b/.justfile index 5b36d3496..db8231023 100644 --- a/.justfile +++ b/.justfile @@ -13,6 +13,9 @@ mod test ".config/commands/test.justfile" # Docker-related commands mod docker ".config/commands/docker.justfile" +# Some utils, e.g. ERD-generation etc. +mod utils ".config/commands/utils.justfile" + # Opens the MaMpf wiki in the default browser wiki: #!/usr/bin/env bash diff --git a/.vscode/settings.json b/.vscode/settings.json index c9fefa3fe..4b4d1cfa7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -102,12 +102,32 @@ ////////////////////////////////////// // Spell Checker ////////////////////////////////////// + "cSpell.enabled": true, + "cSpell.ignorePaths": [ + "node_modules", + ".git" + ], + "cSpell.language": "en,de", "cSpell.words": [ + "activerecord", + "ajax", "commontator", + "cospeaker", + "cospeakers", + "datetime", "factorybot", "helpdesk", "katex", + "preselection", + "selectize", "Timecop", - "turbolinks" - ] + "turbolinks", + "uncached", + "whitespaces" + ], + "cSpell.enableFiletypes": [ + "ruby" + // Other filetypes are handled by the default spell checker + ], + "cSpell.maxNumberOfProblems": 10000 } \ No newline at end of file diff --git a/app/abilities/user_ability.rb b/app/abilities/user_ability.rb index 3cfabfd8a..725d2d52b 100644 --- a/app/abilities/user_ability.rb +++ b/app/abilities/user_ability.rb @@ -14,11 +14,7 @@ def initialize(user) user.admin? || (!user.generic? && user == given_user) end - can :fill_user_select, User do - user.active_teachable_editor? - end - - can :list_generic_users, User do + can [:fill_user_select, :list_generic_users], User do user.admin? end end diff --git a/app/abilities/voucher_ability.rb b/app/abilities/voucher_ability.rb index 173c693ae..9f491d4d4 100644 --- a/app/abilities/voucher_ability.rb +++ b/app/abilities/voucher_ability.rb @@ -7,5 +7,7 @@ def initialize(user) can [:create, :invalidate], Voucher do |voucher| user.can_update_personell?(voucher.lecture) end + + can [:verify, :redeem, :cancel], Voucher end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ea2e6c943..dfe2dbb0f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,6 +8,7 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! before_action :set_locale after_action :store_interaction, if: :user_signed_in? + before_action :set_current_user etag { current_user.try(:id) } @@ -135,4 +136,9 @@ def cookie_locale_param def available_locales I18n.available_locales.map(&:to_s) end + + # https://stackoverflow.com/a/69313330/ + def set_current_user + Current.user = current_user + end end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/controllers/cypress/i18n_controller.rb b/app/controllers/cypress/i18n_controller.rb index cc3e5ec1a..cd68c4e91 100644 --- a/app/controllers/cypress/i18n_controller.rb +++ b/app/controllers/cypress/i18n_controller.rb @@ -10,16 +10,16 @@ def create substitutions = {} if params[:substitutions].present? - unless params[:substitutions].is_a?(Hash) - msg = "Argument `substitution` must be a hash indicating the substitutions." - msg += " But we got: '#{params[:substitutions]}'" + begin + substitutions = params[:substitutions].to_unsafe_hash.symbolize_keys + rescue NoMethodError + msg = "Argument `substitution` is '#{params[:substitutions]}'." + msg += " We cannot convert that to a hash." raise(ArgumentError, msg) end - substitutions = params[:substitutions].to_unsafe_hash.symbolize_keys end i18n_key = params[:i18n_key] - render json: I18n.t(i18n_key, **substitutions), status: :created end end diff --git a/app/controllers/cypress/user_creator_controller.rb b/app/controllers/cypress/user_creator_controller.rb index 41366accc..29855eb03 100644 --- a/app/controllers/cypress/user_creator_controller.rb +++ b/app/controllers/cypress/user_creator_controller.rb @@ -16,6 +16,9 @@ def create user = User.create(name: "#{role} Cypress #{random_hash}", email: "#{role}-#{random_hash}@mampf.cypress", + # Note that some Cypress tests rely on the username + # beginning with "cy" (!) + name_in_tutorials: "cy-#{role}-#{random_hash}", password: CYPRESS_PASSWORD, consents: true, admin: is_admin, locale: I18n.default_locale) user.confirm diff --git a/app/controllers/lecture_notifier.rb b/app/controllers/lecture_notifier.rb new file mode 100644 index 000000000..a35103641 --- /dev/null +++ b/app/controllers/lecture_notifier.rb @@ -0,0 +1,45 @@ +module LectureNotifier + extend self + + def notify_new_editor_by_mail(editor, lecture) + LectureNotificationMailer.with(recipient: editor, + locale: editor.locale, + lecture: lecture) + .new_editor_email.deliver_later + end + + def notify_about_teacher_change_by_mail(lecture, previous_teacher) + notify_new_teacher_by_mail(lecture) + notify_previous_teacher_by_mail(previous_teacher, lecture) + end + + def notify_cospeakers_by_mail(speaker, talks) + talks.each do |talk| + talk.speakers.each do |cospeaker| + next if cospeaker == speaker + + LectureNotificationMailer.with(recipient: cospeaker, + locale: cospeaker.locale, + talk: talk, + speaker: speaker) + .new_speaker_email.deliver_later + end + end + end + + private + + def notify_new_teacher_by_mail(lecture) + LectureNotificationMailer.with(recipient: lecture.teacher, + locale: lecture.teacher.locale, + lecture: lecture) + .new_teacher_email.deliver_later + end + + def notify_previous_teacher_by_mail(previous_teacher, lecture) + LectureNotificationMailer.with(recipient: previous_teacher, + locale: previous_teacher.locale, + lecture: lecture) + .previous_teacher_email.deliver_later + end +end diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb index 12fb42bdd..d0b3b1a48 100644 --- a/app/controllers/lectures_controller.rb +++ b/app/controllers/lectures_controller.rb @@ -66,15 +66,16 @@ def edit def create @lecture = Lecture.new(lecture_params) + @lecture.teacher = current_user unless current_user.admin? authorize! :create, @lecture @lecture.save if @lecture.valid? @lecture.update(sort: "special") if @lecture.course.term_independent # set organizational_concept to default set_organizational_defaults - # set lenguage to default language + # set language to default language set_language - # depending on where the create action was trriggered from, return + # depending on where the create action was triggered from, return # to admin index view or edit course view unless params[:lecture][:from] == "course" redirect_to administration_path, @@ -105,10 +106,7 @@ def update recipients = User.where(id: new_ids) recipients.each do |r| - NotificationMailer.with(recipient: r, - locale: r.locale, - lecture: @lecture) - .new_editor_email.deliver_later + LectureNotifier.notify_new_editor_by_mail(r, @lecture) end end @@ -336,9 +334,10 @@ def lecture_params :submission_max_team_size, :submission_grace_period, :annotations_status] if action_name == "update" && current_user.can_update_personell?(@lecture) - allowed_params.push(:teacher_id, { editor_ids: [] }) + allowed_params.push({ editor_ids: [] }) end - allowed_params.push(:course_id, :teacher_id, { editor_ids: [] }) if action_name == "create" + allowed_params.push(:course_id, { editor_ids: [] }) if action_name == "create" + allowed_params.push(:teacher_id) if current_user.admin? params.require(:lecture).permit(allowed_params) end diff --git a/app/controllers/vouchers_controller.rb b/app/controllers/vouchers_controller.rb index 06a25b7e2..e73dc38cb 100644 --- a/app/controllers/vouchers_controller.rb +++ b/app/controllers/vouchers_controller.rb @@ -26,9 +26,35 @@ def invalidate end end + def verify + @voucher = Voucher.find_voucher_by_hash(params[:secure_hash]) + respond_to do |format| + if @voucher + format.js + format.html { head :no_content } + else + error_message = I18n.t("controllers.voucher_invalid") + format.js { render "error", locals: { error_message: error_message } } + format.html { redirect_to edit_profile_path, alert: error_message } + end + end + end + def redeem - # TODO: this will be dealt with in the corresponding 2nd PR - render js: "alert('Voucher redeemed!')" + voucher = Voucher.find_voucher_by_hash(params[:secure_hash]) + if voucher + voucher.redeem(params.permit(tutorial_ids: [], talk_ids: [])) + redirect_to edit_profile_path, notice: success_message(voucher) + else + handle_invalid_voucher + end + end + + def cancel + respond_to do |format| + format.html { redirect_to edit_profile_path } + format.js + end end private @@ -50,6 +76,18 @@ def set_related_data I18n.locale = @lecture.locale end + def success_message(voucher) + if voucher.tutor? + I18n.t("controllers.become_tutor_success") + elsif voucher.editor? + I18n.t("controllers.become_editor_success") + elsif voucher.teacher? + I18n.t("controllers.become_teacher_success") + elsif voucher.speaker? + I18n.t("controllers.become_speaker_success") + end + end + def handle_successful_save(format) format.html { redirect_to edit_lecture_path(@lecture, anchor: "people") } format.js @@ -80,4 +118,12 @@ def handle_voucher_not_found end end end + + def handle_invalid_voucher + error_message = I18n.t("controllers.voucher_invalid") + respond_to do |format| + format.js { render "error", locals: { error_message: error_message } } + format.html { redirect_to edit_profile_path, alert: error_message } + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f267191e3..495f7a96e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -327,4 +327,10 @@ def get_class_for_any_path(paths) def get_class_for_any_path_startswith(paths) paths.any? { |path| request.path.starts_with?(path) } ? ACTIVE_CSS_CLASS : "" end + + def truncate_result(result, length = 40) + result.first(length).tap do |truncated| + return truncated.length < length ? truncated : "#{truncated}..." + end + end end diff --git a/app/helpers/lectures_helper.rb b/app/helpers/lectures_helper.rb index b7d7f739e..bd9757890 100644 --- a/app/helpers/lectures_helper.rb +++ b/app/helpers/lectures_helper.rb @@ -129,4 +129,81 @@ def lecture_view_icon(lecture) tag.i(class: "fas fa-eye") end end + + def editors_preselection(lecture) + options_for_select(lecture.eligible_as_editors.map do |editor| + [editor.info, editor.id] + end, lecture.editor_ids) + end + + def teacher_select(form, is_new_lecture, lecture = nil) + if current_user.admin? + label = form.label(:teacher_id, t("basics.teacher"), class: "form-label") + help_desk = helpdesk(t("admin.lecture.info.teacher"), false) + + preselection = if is_new_lecture + options_for_select([[current_user.info, current_user.id]], current_user.id) + else + options_for_select([[lecture.teacher.info, lecture.teacher.id]], lecture.teacher.id) + end + + # TODO: Rubocop bug when trying to break the last object on a new line + select = form.select(:teacher_id, preselection, {}, { class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), # rubocop:disable Layout/LineLength + no_results: t("basics.no_results"), + modal: true, + cy: "teacher-admin-select" + } }) + + error_div = content_tag(:div, "", class: "invalid-feedback", id: "lecture-teacher-error") + + return label + help_desk + select + error_div + end + + # Non-admin cases + if is_new_lecture + p1 = content_tag(:p) do + concat(t("basics.teacher")) + concat(helpdesk(t("admin.lecture.info.teacher_fixed_new_lecture"), false)) + end + p2 = content_tag(:p, current_user.info) + + else + p1 = content_tag(:p) do + concat(t("basics.teacher")) + concat(helpdesk(t("admin.lecture.info.teacher_fixed"), false)) + end + p2 = content_tag(:p, lecture.teacher.info, "data-cy": "teacher-info") + end + + p1 + p2 + end + + def editors_select(form, lecture) + if current_user.admin? + preselection = options_for_select(lecture.select_editors, lecture.editors.map(&:id)) + form.select(:editor_ids, preselection, {}, { + class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), + no_results: t("basics.no_results"), + modal: true + } + }) + else + form.select(:editor_ids, editors_preselection(lecture), {}, + class: "selectize", + multiple: true, + "data-cy": "lecture-editors-select") + end + end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index be7160ec2..6efe5a44e 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -7,6 +7,7 @@ def notification_menu_item_header(notification) return medium_notification_item_header(notifiable) if notification.medium? return course_notification_item_header(notifiable) if notification.course? return lecture_notification_item_header(notifiable) if notification.lecture? + return redemption_notification_item_header(notifiable) if notification.redemption? announcement_notification_item_header(notifiable) end @@ -17,6 +18,7 @@ def notification_menu_item_details(notification) return medium_notification_item_details(notifiable) if notification.medium? return course_notification_item_details(notifiable) if notification.course? return lecture_notification_item_details(notifiable) if notification.lecture? + return redemption_notification_item_details(notifiable) if notification.redemption? "" end @@ -26,6 +28,7 @@ def notification_color(notification) return "bg-post-it-blue" if notification.generic_announcement? return "bg-post-it-red" if notification.announcement? return "bg-post-it-orange" if notification.course? || notification.lecture? + return "bg-post-it-green" if notification.redemption? "bg-post-it-yellow" end @@ -39,6 +42,8 @@ def notification_header(notification) t("notifications.course_selection") elsif notification.lecture_announcement? announcement_notification_card_header(notifiable) + elsif notification.redemption? + redemption_notification_card_header(notifiable) else link_to(t("mampf_news.title"), news_path, class: "text-dark") end @@ -53,6 +58,8 @@ def notification_text(notification) course_notification_card_text(notifiable) elsif notification.lecture? lecture_notification_card_text(notifiable) + elsif notification.redemption? + t("notifications.redemption") else t("notifications.new_announcement") end @@ -69,6 +76,8 @@ def notification_link(notification) course_notification_card_link elsif notification.lecture? lecture_notification_card_link + elsif notification.redemption? + redemption_notification_details(notifiable) else notifiable.details end diff --git a/app/helpers/redemptions_helper.rb b/app/helpers/redemptions_helper.rb new file mode 100644 index 000000000..50cbad850 --- /dev/null +++ b/app/helpers/redemptions_helper.rb @@ -0,0 +1,102 @@ +# Redemptions Helper +module RedemptionsHelper + def redemption_notification_card_header(redemption) + link_to(redemption.voucher.lecture.title_for_viewers, + edit_lecture_path(redemption.voucher.lecture, + anchor: ("people" unless redemption.voucher.speaker?)), + class: "text-dark") + end + + def redemption_notification_item_header(redemption) + t("notifications.redemption_in_lecture", + lecture: redemption.voucher.lecture.title_for_viewers) + end + + def redemption_notification_details(redemption) + if redemption.voucher.tutor? + tutor_notification_details(redemption) + elsif redemption.voucher.editor? + editor_notification_details(redemption) + elsif redemption.voucher.teacher? + teacher_notification_details(redemption) + else + speaker_notification_details(redemption) + end + end + + def redemption_notification_item_details(redemption) + result = if redemption.voucher.tutor? + tutor_notification_item_details(redemption) + elsif redemption.voucher.editor? + editor_notification_item_details(redemption) + elsif redemption.voucher.teacher? + teacher_notification_item_details(redemption) + else + speaker_notification_item_details(redemption) + end + + truncate_result(result) + end + + private + + def tutor_notification_item_details(redemption) + tutorials = redemption.claimed_tutorials + tutorial_details = tutorials.map(&:title).join(", ") + + base_message = "#{t("basics.tutor")} #{redemption.user.tutorial_name}" + tutorials.any? ? "#{base_message}: #{tutorial_details}" : base_message + end + + def editor_notification_item_details(redemption) + "#{t("basics.editor")} #{redemption.user.tutorial_name}" + end + + def teacher_notification_item_details(redemption) + "#{t("basics.teacher")} #{redemption.user.tutorial_name}" + end + + def speaker_notification_item_details(redemption) + talks = redemption.claimed_talks + talk_details = talks.map(&:to_label).join(", ") + + base_message = "#{t("basics.speaker")} #{redemption.user.tutorial_name}" + talks.any? ? "#{base_message}: #{talk_details}" : base_message + end + + def tutor_notification_details(redemption) + user_info = I18n.t("notifications.became_tutor", user: redemption.user.info) + tutorials = redemption.claimed_tutorials + + tutorial_details = if tutorials.present? + I18n.t("notifications.tutorial_details", + tutorials: tutorials.map(&:title).join(", ")) + else + I18n.t("notifications.no_tutorials_taken") + end + + user_info + tutorial_details + end + + def editor_notification_details(redemption) + I18n.t("notifications.became_editor", user: redemption.user.info) + end + + def teacher_notification_details(redemption) + I18n.t("notifications.became_teacher", user: redemption.user.info) + end + + def speaker_notification_details(redemption) + user_info = I18n.t("notifications.became_speaker", user: redemption.user.info) + talks = redemption.claimed_talks + + talk_details = if talks.present? + I18n.t("notifications.talk_details", + talks: talks.map(&:to_label).join(", ")) + else + I18n.t("notifications.no_talks_taken") + end + + user_info + talk_details + end +end diff --git a/app/helpers/talks_helper.rb b/app/helpers/talks_helper.rb index 4546dba81..3783bb400 100644 --- a/app/helpers/talks_helper.rb +++ b/app/helpers/talks_helper.rb @@ -42,4 +42,39 @@ def date_list(talk) def cospeaker_list(talk, user) (talk.speakers.to_a - [user]).map(&:tutorial_name).join(", ") end + + def speakers_preselection(talk) + options_for_select(talk.lecture.eligible_as_speakers.map do |s| + [s.tutorial_info, s.id] + end, talk.speaker_ids) + end + + def speaker_select(form, talk, with_preselection) + label = form.label(:speaker_ids, t("admin.talk.speakers"), class: "form-label") + help_desk = helpdesk(t("admin.talk.info.speakers"), false) + + select = if current_user.admin? + preselection = with_preselection ? speakers_preselection(talk) : [[]] + form.select(:speaker_ids, preselection, {}, { + class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), + no_results: t("basics.no_results"), + modal: true, + cy: "speaker-select" + } + }) + else + form.select(:speaker_ids, speakers_preselection(talk), {}, + class: "selectize", + data: { cy: "speaker-select" }, + multiple: true) + end + + label + help_desk + select + end end diff --git a/app/helpers/tutorials_helper.rb b/app/helpers/tutorials_helper.rb index 7cb929bf7..533099896 100644 --- a/app/helpers/tutorials_helper.rb +++ b/app/helpers/tutorials_helper.rb @@ -6,11 +6,10 @@ def cancel_editing_tutorial_path(tutorial) cancel_new_tutorial_path(params: { lecture: tutorial.lecture }) end - def tutorial_preselection(tutorial) - return [[]] unless tutorial.persisted? && tutorial.tutors.any? - - options_for_select(tutorial.tutors.map { |t| [t.tutorial_info, t.id] }, - tutorial.tutor_ids) + def tutors_preselection(tutorial) + options_for_select(tutorial.lecture.eligible_as_tutors.map do |t| + [t.tutorial_info, t.id] + end, tutorial.tutor_ids) end def tutorials_selection(lecture) diff --git a/app/helpers/vouchers_helper.rb b/app/helpers/vouchers_helper.rb new file mode 100644 index 000000000..9bde9da15 --- /dev/null +++ b/app/helpers/vouchers_helper.rb @@ -0,0 +1,50 @@ +module VouchersHelper + def tutorial_options(user, voucher) + voucher.lecture.tutorials_without_tutor(user).map { |t| [t.title, t.id] } + end + + def given_tutorial_ids(user, voucher) + user.given_tutorials.where(lecture: voucher.lecture).pluck(:id) + end + + def tutorials_with_tutor_titles(user, voucher) + voucher.lecture.tutorials_with_tutor(user).map(&:title).join(", ") + end + + def talks_with_titles(user, voucher) + voucher.lecture.talks_with_speaker(user).map(&:to_label).join(", ") + end + + def talk_options(user, voucher) + voucher.lecture.talks_without_speaker(user) + .map { |t| [t.to_label_with_speakers, t.id] } + end + + def redeem_voucher_button(voucher) + link_to(t("profile.redeem_voucher"), + redeem_voucher_path(params: { secure_hash: voucher.secure_hash }), + class: "btn btn-primary", + data: { cy: "redeem-voucher-btn" }, + method: :post, remote: true) + end + + def cancel_voucher_button + link_to(t("buttons.cancel"), cancel_voucher_path, + class: "btn btn-secondary ms-2", data: { cy: "cancel-voucher-btn" }, + remote: true) + end + + def claim_select_field(form, user, voucher) + field_name, options, prompt = if voucher.tutor? + [:tutorial_ids, tutorial_options(user, voucher), t("profile.select_tutorials")] + elsif voucher.speaker? + [:talk_ids, talk_options(user, voucher), t("profile.select_talks")] + end + + form.select(field_name, + options_for_select(options), + { prompt: prompt }, + { multiple: true, class: "selectize me-2", style: "width: 20rem", + data: { cy: "claim-select" } }) + end +end diff --git a/app/mailers/lecture_notification_mailer.rb b/app/mailers/lecture_notification_mailer.rb new file mode 100644 index 000000000..62263765a --- /dev/null +++ b/app/mailers/lecture_notification_mailer.rb @@ -0,0 +1,51 @@ +class LectureNotificationMailer < ApplicationMailer + before_action { I18n.locale = params[:locale] } + before_action { @sender = NotificationMailer.sender(params[:locale]) } + + def new_editor_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_editor_subject", + title: @lecture.title_for_viewers)) + end + + def new_teacher_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_teacher_subject", + title: @lecture.title_for_viewers)) + end + + def previous_teacher_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.previous_teacher_subject", + title: @lecture.title_for_viewers, + new_teacher: @lecture.teacher.tutorial_name)) + end + + def new_speaker_email + @talk = params[:talk] + @recipient = params[:recipient] + @speaker = params[:speaker].info + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_speaker_subject", + seminar: @talk.lecture.title, + title: @talk.to_label)) + end +end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index ae6a281c1..8445cbe7d 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -2,7 +2,6 @@ class NotificationMailer < ApplicationMailer before_action :set_sender_and_locale before_action :set_recipients, only: [:medium_email, :announcement_email, :new_lecture_email, - :new_editor_email, :submission_deletion_email, :submission_deletion_lecture_email, :submission_destruction_email, @@ -51,17 +50,6 @@ def new_lecture_email title: @lecture.title_for_viewers)) end - def new_editor_email - @lecture = params[:lecture] - @recipient = params[:recipient] - @username = @recipient.tutorial_name - - mail(from: @sender, - to: @recipient.email, - subject: t("mailer.new_editor_subject", - title: @lecture.title_for_viewers)) - end - def submission_invitation_email @recipient = params[:recipient] @assignment = params[:assignment] @@ -173,8 +161,16 @@ def submission_destruction_lecture_email lecture: @lecture.title)) end + def self.sender(locale) + I18n.t("mailer.notification", locale: locale) \ + +" <#{DefaultSetting::PROJECT_NOTIFICATION_EMAIL}>" + end + private + # This method should be replaced by the one above (self.sender). + # It only stays here during the transition phase where this file is split + # into multiple files regarding concerns like vouchers, submissions, etc. def set_sender_and_locale @sender = "#{t("mailer.notification")} <#{DefaultSetting::PROJECT_NOTIFICATION_EMAIL}>" I18n.locale = params[:locale] diff --git a/app/models/announcement.rb b/app/models/announcement.rb index 4504b0ff7..c1f2f4c28 100644 --- a/app/models/announcement.rb +++ b/app/models/announcement.rb @@ -4,6 +4,8 @@ class Announcement < ApplicationRecord belongs_to :lecture, optional: true, touch: true belongs_to :announcer, class_name: "User" + has_many :notifications, as: :notifiable, dependent: :destroy + validates :details, presence: true paginates_per 10 diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/models/course.rb b/app/models/course.rb index 5edf63bfa..574e1f4e6 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -4,6 +4,8 @@ class Course < ApplicationRecord has_many :lectures, dependent: :destroy + has_many :notifications, as: :notifiable, dependent: :destroy + # tags are notions that treated in the course # e.g.: vector space, linear map are tags for the course 'Linear Algebra 1' has_many :course_tag_joins, dependent: :destroy diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..619f480c5 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +# https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 28796e2fe..c42267298 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -4,6 +4,8 @@ class Lecture < ApplicationRecord belongs_to :course + has_many :notifications, as: :notifiable, dependent: :destroy + # teacher is the user that gives the lecture belongs_to :teacher, class_name: "User" @@ -849,6 +851,85 @@ def active_voucher_of_role(role) vouchers.where(role: role).active&.first end + def update_tutor_status!(user, selected_tutorials) + tutorials.find_each do |t| + t.add_tutor(user) if selected_tutorials.include?(t) + end + # touch to invalidate the cache + touch + end + + def update_editor_status!(user) + return if editors.include?(user) + + editors << user + # touch to invalidate the cache + touch + end + + def update_teacher_status!(user) + return if teacher == user + + previous_teacher = teacher + update(teacher: user) + editors << previous_teacher + # touch to invalidate the cache + touch + end + + def update_speaker_status!(user, selected_talks) + talks.find_each do |t| + t.add_speaker(user) if selected_talks.include?(t) + end + # touch to invalidate the cache + touch + end + + def eligible_as_tutors + (tutors + Redemption.tutors_by_redemption_in(self) + editors + [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some tutor statuses were + # still given by the old system, this will not be true + end + + def eligible_as_editors + (editors + Redemption.editors_by_redemption_in(self) + course.editors - [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some editor statuses were + # still given by the old system, this will not be true + end + + def eligible_as_teachers + (User.teachers + editors + course.editors + [teacher]).uniq + end + + def eligible_as_speakers + (speakers + Redemption.speakers_by_redemption_in(self) + editors + [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some editor statuses were + # still given by the old system, this will not be true + end + + def editors_and_teacher + ([teacher] + editors).uniq + end + + def tutorials_with_tutor(tutor) + tutorials.where(id: tutorial_ids_for_tutor(tutor)) + end + + def tutorials_without_tutor(tutor) + tutorials.where.not(id: tutorial_ids_for_tutor(tutor)) + end + + def talks_with_speaker(speaker) + talks.where(id: talk_ids_for_speaker(speaker)) + end + + def talks_without_speaker(speaker) + talks.where.not(id: talk_ids_for_speaker(speaker)) + end + private # used for after save callback @@ -959,4 +1040,12 @@ def older_than?(timespan) term.begin_date <= Term.active.begin_date - timespan end + + def tutorial_ids_for_tutor(tutor) + TutorTutorialJoin.where(tutor: tutor).select(:tutorial_id) + end + + def talk_ids_for_speaker(speaker) + SpeakerTalkJoin.where(speaker: speaker).select(:talk_id) + end end diff --git a/app/models/medium.rb b/app/models/medium.rb index ab667c7cb..bd59866d5 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -3,6 +3,8 @@ class Medium < ApplicationRecord include ApplicationHelper include ActiveModel::Dirty + has_many :notifications, as: :notifiable, dependent: :destroy + # a teachable is a course/lecture/lesson belongs_to :teachable, polymorphic: true, optional: true acts_as_list scope: [:teachable_id, :teachable_type], top_of_list: 0 diff --git a/app/models/notification.rb b/app/models/notification.rb index ee62b890c..fb0627215 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -9,19 +9,12 @@ class Notification < ApplicationRecord paginates_per 12 - # retrieve notifiable defined by notifiable_type and notifiable_id - # def notifiable - # return unless notifiable_type.in?(Notification.allowed_notifiable_types) && - # notifiable_id.present? - # notifiable_type.constantize.find_by_id(notifiable_id) - # end - # returns the lecture associated to a notification of type announcement, # and teachable for a notification of type medium, nil otherwise def teachable return if notifiable.blank? - return if notifiable_type.in?(["Lecture", "Course"]) - return notifiable.lecture if notifiable_type == "Announcement" + return if lecture_or_course? + return notifiable.lecture if announcement_or_redemption? # notifiable will be a medium, so return its teachable notifiable.teachable @@ -34,46 +27,42 @@ def teachable # all other cases: notifiable path def path(user) return if notifiable.blank? - return edit_profile_path if notifiable_type.in?(["Course", "Lecture"]) - - if notifiable_type == "Announcement" - return notifiable.lecture.path(user) if notifiable.lecture.present? - return news_path + if redemption? + edit_lecture_path(notifiable.voucher.lecture, anchor: "people") + elsif lecture_or_course? + edit_profile_path + elsif lecture_announcement? + notifiable.lecture.path(user) + elsif generic_announcement? + news_path + elsif quiz? + medium_path(notifiable) + else + polymorphic_url(notifiable, only_path: true) end - return medium_path(notifiable) if notifiable_type == "Medium" && notifiable.sort == "Quiz" - - polymorphic_url(notifiable, only_path: true) - end - - def self.allowed_notifiable_types - ["Medium", "Course", "Lecture", "Announcement"] end # the next methods are for the determination which kind of notification it is def medium? - return false if notifiable.blank? - - notifiable_type == "Medium" + notifiable.is_a?(Medium) end def course? - return false if notifiable.blank? - - notifiable.instance_of?(::Course) + notifiable.is_a?(Course) end def lecture? - return false if notifiable.blank? + notifiable.is_a?(Lecture) + end - notifiable.instance_of?(::Lecture) + def redemption? + notifiable.is_a?(Redemption) end def announcement? - return false if notifiable.blank? - - notifiable.instance_of?(::Announcement) + notifiable.is_a?(Announcement) end def generic_announcement? @@ -83,4 +72,18 @@ def generic_announcement? def lecture_announcement? announcement? && notifiable.lecture.present? end + + def quiz? + medium? && notifiable.sort == "Quiz" + end + + private + + def lecture_or_course? + notifiable_type.in?(["Lecture", "Course"]) + end + + def announcement_or_redemption? + notifiable_type.in?(["Announcement", "Redemption"]) + end end diff --git a/app/models/talk.rb b/app/models/talk.rb index 2afef84a1..650eb1f22 100644 --- a/app/models/talk.rb +++ b/app/models/talk.rb @@ -4,6 +4,8 @@ class Talk < ApplicationRecord has_many :speaker_talk_joins, dependent: :destroy has_many :speakers, through: :speaker_talk_joins + has_many :claims, as: :claimable, dependent: :destroy + validates :title, presence: true # being a teachable (course/lecture/lesson), a talk has associated media @@ -35,6 +37,12 @@ def to_label I18n.t("talk", number: position, title: title) end + def to_label_with_speakers + return to_label unless speakers.any? + + "#{to_label} (#{speakers.map(&:tutorial_name).join(", ")})" + end + def long_title title_for_viewers end @@ -100,6 +108,10 @@ def editors_with_inheritance (speakers + lecture.editors_with_inheritance).uniq end + def add_speaker(speaker) + speakers << speaker unless speaker.in?(speakers) + end + private def touch_lecture diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 824f390cf..8fd72418a 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -9,6 +9,8 @@ class Tutorial < ApplicationRecord has_many :submissions, dependent: :destroy + has_many :claims, as: :claimable, dependent: :destroy + before_destroy :check_destructibility, prepend: true # rubocop:todo Rails/UniqueValidationWithoutIndex @@ -41,6 +43,10 @@ def teams_to_csv(assignment) end end + def add_tutor(tutor) + tutors << tutor unless tutors.include?(tutor) + end + private def check_destructibility diff --git a/app/models/user.rb b/app/models/user.rb index 4d3337b0a..5040624df 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -81,6 +81,9 @@ class User < ApplicationRecord has_many :feedbacks, dependent: :destroy + # a user has redemptions of vouchers + has_many :redemptions, dependent: :destroy + include ScreenshotUploader[:image] # if a homepage is given it should at leat be a valid address diff --git a/app/models/voucher/claim.rb b/app/models/voucher/claim.rb new file mode 100644 index 000000000..e8fe5aa6d --- /dev/null +++ b/app/models/voucher/claim.rb @@ -0,0 +1,6 @@ +# A Claim stores a Claimable that is being taken over by the user when they +# redeem a voucher. Claimables include tutorials and talks. +class Claim < ApplicationRecord + belongs_to :redemption + belongs_to :claimable, polymorphic: true +end diff --git a/app/models/voucher/redeemer.rb b/app/models/voucher/redeemer.rb new file mode 100644 index 000000000..ad9ede97c --- /dev/null +++ b/app/models/voucher/redeemer.rb @@ -0,0 +1,78 @@ +# The Redeemer module is included in the Voucher model to encapsulate the +# redemption logic of a voucher. +# +# Note that this is not the same as "Claimable", which is used for roles +# that can be claimed via a voucher, e.g. becoming a tutor for a lecture etc. +module Redeemer + extend ActiveSupport::Concern + + included do + has_many :redemptions, dependent: :destroy + end + + def redeem(params) + redemption = create_redemption(params) + create_notifications!(redemption) + Current.user.subscribe_lecture!(lecture) + end + + private + + def create_redemption(params) + case role.to_sym + when :tutor + redeem_tutor_voucher(params[:tutorial_ids]) + when :editor + redeem_editor_voucher + when :teacher + redeem_teacher_voucher + when :speaker + redeem_speaker_voucher(params[:talk_ids]) + end + end + + def redeem_tutor_voucher(tutorial_ids) + selected_tutorials = lecture.tutorials.where(id: tutorial_ids) + lecture.update_tutor_status!(Current.user, selected_tutorials) + + Redemption.create(user: Current.user, voucher: self, + claimed_tutorials: selected_tutorials) + end + + def redeem_editor_voucher + lecture.update_editor_status!(Current.user) + LectureNotifier.notify_new_editor_by_mail(Current.user, lecture) + + Redemption.create(user: Current.user, voucher: self) + end + + def redeem_teacher_voucher + previous_teacher = lecture.teacher + lecture.update_teacher_status!(Current.user) + # no need to send out notifications if the teacher stays the same + # because then there is no demotion to editor + # (it is actually not possible to trigger this case via the GUI) + if previous_teacher != Current.user + LectureNotifier.notify_about_teacher_change_by_mail(lecture, + previous_teacher) + end + invalidate! + + Redemption.create(user: Current.user, voucher: self) + end + + def redeem_speaker_voucher(talk_ids) + selected_talks = lecture.talks.where(id: talk_ids) + lecture.update_speaker_status!(Current.user, selected_talks) + LectureNotifier.notify_cospeakers_by_mail(Current.user, selected_talks) + + Redemption.create(user: Current.user, voucher: self, + claimed_talks: selected_talks) + end + + def create_notifications!(redemption) + lecture.editors_and_teacher.each do |editor| + Notification.create(notifiable: redemption, recipient: editor) + end + end +end diff --git a/app/models/voucher/redemption.rb b/app/models/voucher/redemption.rb new file mode 100644 index 000000000..2e6e68176 --- /dev/null +++ b/app/models/voucher/redemption.rb @@ -0,0 +1,44 @@ +# Redemptions store the event of a user redeeming a voucher. +# +# During one redemption of a voucher, a user might claim multiple objects, e.g. +# two tutorial slots. The respective claims are stored in the Claims model. +# +# Also provides class methods to find out about users who have redeemed +# specific vouchers, e.g. tutors by redemption in a given lecture. +class Redemption < ApplicationRecord + belongs_to :voucher + belongs_to :user + has_many :claims, dependent: :destroy + has_many :claimed_tutorials, through: :claims, source: :claimable, + source_type: Tutorial.name + has_many :claimed_talks, through: :claims, source: :claimable, + source_type: Talk.name + + has_many :notifications, as: :notifiable, dependent: :destroy + + class << self + def tutors_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_tutors) + end + + def editors_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_editors) + end + + def speakers_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_speakers) + end + + private + + # Returns the users who have redeemed the given vouchers. + # + # These users could be called "Redeemers", but note that this should not + # be confused with the Redeemer module that is responsible for the redemption + # process of a voucher. + def users_that_redeemed_vouchers(relevant_vouchers) + user_ids = Redemption.where(voucher: relevant_vouchers).pluck(:user_id) + User.where(id: user_ids.uniq) + end + end +end diff --git a/app/models/voucher.rb b/app/models/voucher/voucher.rb similarity index 59% rename from app/models/voucher.rb rename to app/models/voucher/voucher.rb index 1517a8708..ccd1ac2da 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher/voucher.rb @@ -1,4 +1,20 @@ +# A voucher is a unique (secure) hash that can be used by users to redeem a role, +# such as tutor, teacher etc. That is, the voucher grants the user elevated +# permissions. +# +# Vouchers are created by lecture editors, e.g. teachers. They will then send +# the voucher to the user by means of a different communication channel, +# e.g. email. Users can redeem the voucher by entering the code on their +# profile page. +# +# Before the introduction of vouchers, teachers could select from the whole pool +# of MaMpf users to assign them a role, e.g. to select tutors for their lecture. +# To better align this process with GDPR requirements, the concept of voucher +# was introduced. This way, teachers can only assign roles to users who have +# actively redeemed a voucher. class Voucher < ApplicationRecord + include Redeemer + SPEAKER_EXPIRATION_DAYS = 30 TUTOR_EXPIRATION_DAYS = 14 DEFAULT_EXPIRATION_DAYS = 3 @@ -18,6 +34,9 @@ class Voucher < ApplicationRecord where("expires_at > ? AND invalidated_at IS NULL", Time.zone.now) } + scope :for_tutors, -> { where(role: :tutor) } + scope :for_editors, -> { where(role: :editor) } + scope :for_speakers, -> { where(role: :speaker) } self.implicit_order_column = :created_at @@ -27,6 +46,15 @@ def self.roles_for_lecture(lecture) ROLE_HASH.keys - [:speaker] end + def self.find_voucher_by_hash(secure_hash) + # strip() to avoid issues with leading/trailing whitespaces when copy-pasting + Voucher.active.find_by(secure_hash: secure_hash.strip) + end + + def invalidate! + update(invalidated_at: Time.zone.now) + end + private def generate_secure_hash diff --git a/app/views/administration/index/_my_courses.html.erb b/app/views/administration/index/_my_courses.html.erb index 93d8112bb..eb74229c8 100644 --- a/app/views/administration/index/_my_courses.html.erb +++ b/app/views/administration/index/_my_courses.html.erb @@ -18,12 +18,14 @@
- + <% if current_user.admin? %> + + <% end %> <% if current_user.edited_courses.any? %> <%= render partial: 'administration/index/courses_card', locals: { courses: diff --git a/app/views/layouts/application_no_sidebar.html.erb b/app/views/layouts/application_no_sidebar.html.erb index e23124bb0..24d45f890 100644 --- a/app/views/layouts/application_no_sidebar.html.erb +++ b/app/views/layouts/application_no_sidebar.html.erb @@ -17,7 +17,7 @@
<% end %> <% if notice.present? %> - -
- <%= f.label :teacher_id, - t('basics.teacher'), - class: "form-label" %> - <%= helpdesk(t('admin.lecture.info.teacher'), false) %> - <%= f.select :teacher_id, - options_for_select([[current_user.info, current_user.id]], - current_user.id), - {}, - { class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: t('basics.enter_two_letters'), - no_results: t('basics.no_results'), - current: current_user.id, - modal: modal } } %> +
+ <%= teacher_select(f, is_new_lecture=true) %>
diff --git a/app/views/lectures/edit/_form.html.erb b/app/views/lectures/edit/_form.html.erb index c6d441c18..5aee9c997 100644 --- a/app/views/lectures/edit/_form.html.erb +++ b/app/views/lectures/edit/_form.html.erb @@ -30,6 +30,7 @@ id="lecture-nav-people" type="button" role="tab" href="#people" data-bs-toggle="pill" data-bs-target="#lecture-pane-people" + data-cy="people-tab-btn" aria-controls="lecture-pane-people" aria-selected="false"> <%= t('basics.people') %>/<%= t('basics.tutorials') %> @@ -102,10 +103,10 @@
<%= render partial: 'lectures/edit/people', locals: { lecture: lecture } %> - <%= render partial: 'lectures/edit/vouchers', - locals: { lecture: lecture } %> <%= render partial: 'lectures/edit/tutorials', locals: { lecture: lecture } %> + <%= render partial: 'lectures/edit/vouchers', + locals: { lecture: lecture } %>
diff --git a/app/views/lectures/edit/_people.html.erb b/app/views/lectures/edit/_people.html.erb index e1d047f46..63b432872 100644 --- a/app/views/lectures/edit/_people.html.erb +++ b/app/views/lectures/edit/_people.html.erb @@ -3,28 +3,8 @@

<%= t('basics.people') %>

-
- <%= f.label :teacher_id, - t('basics.teacher'), - class: "form-label" %> - <%= helpdesk(t('admin.lecture.info.teacher'), false) %> -
- <%= f.select :teacher_id, - options_for_select([[lecture.teacher.info, - lecture.teacher.id]], - lecture.teacher.id), - {}, - { class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: - t('basics.enter_two_letters'), - no_results: - t('basics.no_results') } } %> -
-
-
+
+ <%= teacher_select(f, is_new_lecture=false, lecture) %>
@@ -32,21 +12,11 @@ t('basics.lecture_editors'), class: "form-label" %> <%= helpdesk(t('admin.lecture.info.lecture_editors'), false) %> -
- <%= f.select :editor_ids, - options_for_select([[t('none'), '']] + - lecture.select_editors, - lecture.editors.map(&:id)), - {}, - { multiple: true, - class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: t('basics.enter_two_letters'), - no_results: t('basics.no_results') } } %> +
+ <%= editors_select(f, lecture) %>
+
diff --git a/app/views/lectures/edit/_seminar_content.html.erb b/app/views/lectures/edit/_seminar_content.html.erb index b7b74ead1..64675bcfd 100644 --- a/app/views/lectures/edit/_seminar_content.html.erb +++ b/app/views/lectures/edit/_seminar_content.html.erb @@ -16,6 +16,7 @@ new_talk_path(lecture_id: lecture.id), remote: true, class: 'btn btn-sm btn-secondary new-in-lecture', + data: { cy: 'new-talk-btn' }, id: 'new_talk_button' %>
@@ -29,7 +30,9 @@ <% end %>
<% else %> - <%= t('admin.lecture.no_talks') %> + + <%= t('admin.lecture.no_talks') %> + <% end %>
diff --git a/app/views/lectures/edit/_tutorials.html.erb b/app/views/lectures/edit/_tutorials.html.erb index 1dded934e..37ae6b55c 100644 --- a/app/views/lectures/edit/_tutorials.html.erb +++ b/app/views/lectures/edit/_tutorials.html.erb @@ -10,6 +10,7 @@ new_tutorial_path(params: { lecture_id: lecture.id }), class: 'btn btn-sm btn-primary', id: 'newTutorialButton', + data: { cy: 'new-tutorial-btn' }, remote: true %> @@ -26,6 +27,7 @@
<%= t('basics.tutors') %> + <%= helpdesk(t('tutorial.info.tutors'), true) %>
diff --git a/app/views/lectures/error.js.erb b/app/views/lectures/error.js.erb new file mode 100644 index 000000000..debc3873c --- /dev/null +++ b/app/views/lectures/error.js.erb @@ -0,0 +1 @@ +alert("<%= "#{j(error_message)}" %>"); diff --git a/app/views/main/start.html.erb b/app/views/main/start.html.erb index 8d16e8ab9..ae4cbc1bd 100644 --- a/app/views/main/start.html.erb +++ b/app/views/main/start.html.erb @@ -63,7 +63,8 @@ data-bs-parent="#subscriptionsAccordion" data-link="#inactiveLecturesLink">
+ id="collapseInactiveLecturesContent" + data-cy="subscribed-inactive-lectures-collapse"> <% if @current_stuff.empty? %> <%= render partial: 'main/start/lecture', collection: @inactive_lectures, @@ -117,7 +118,8 @@ data-bs-toggle="collapse" data-bs-target="#collapseTalks" aria-expanded="false" - aria-controls="collapseTalks"> + aria-controls="collapseTalks" + data-cy="my-talks-collapse-btn">
+ data-link="#talkLink" + data-cy="my-talks-collapse">
<%= render partial: 'main/start/talks', locals: { talks: @talks } %> diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb index 7a668aab3..53f53c784 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -1,5 +1,5 @@ -
-
+
+
@@ -26,7 +26,7 @@
-
+
<%= simple_format(notification_text(notification)) %> <%= simple_format(notification_link(notification)) %>
diff --git a/app/views/profile/_account.html.erb b/app/views/profile/_account.html.erb index 0847f3662..087bfbc38 100644 --- a/app/views/profile/_account.html.erb +++ b/app/views/profile/_account.html.erb @@ -4,5 +4,5 @@ <%= link_to t('profile.delete_account'), delete_account_path, class: "btn btn-outline-danger mb-2", + "data-cy": "delete-account-btn", remote: true %> - diff --git a/app/views/profile/_verify_voucher.html.erb b/app/views/profile/_verify_voucher.html.erb new file mode 100644 index 000000000..8abdb76c9 --- /dev/null +++ b/app/views/profile/_verify_voucher.html.erb @@ -0,0 +1,14 @@ +<%= form_with url: verify_voucher_path, + remote: true, + method: :post, + html: { class: "form-inline", + data: { cy: "verify-voucher-form"} } do |f| %> +
+ <%= f.text_field :secure_hash, class: "form-control me-2", + style: "width: 20rem;", + autocomplete: "off", + data: { cy: "secure-hash-input" } %> + <%= f.submit t('buttons.verify_voucher'), class: "btn btn-primary", + data: { cy: "verify-voucher-submit" } %> +
+<% end %> diff --git a/app/views/profile/edit.html.erb b/app/views/profile/edit.html.erb index 69ba4c401..875d0b3bc 100644 --- a/app/views/profile/edit.html.erb +++ b/app/views/profile/edit.html.erb @@ -110,7 +110,7 @@ <% end %>
-
+
@@ -120,8 +120,8 @@
-
- <%= render partial: 'profile/redeem_voucher' %> +
+ <%= render partial: 'profile/verify_voucher' %>
diff --git a/app/views/shared/_dropdown_notifications.html.erb b/app/views/shared/_dropdown_notifications.html.erb index 5405d3bc0..1db2a2490 100644 --- a/app/views/shared/_dropdown_notifications.html.erb +++ b/app/views/shared/_dropdown_notifications.html.erb @@ -2,7 +2,8 @@ .sort_by(&:created_at).reverse %> <% if relevant_notifications.present? %>