From ec2201f9a8ee8b0ae904062d8a95535203679502 Mon Sep 17 00:00:00 2001 From: sto Date: Sat, 17 May 2025 17:40:03 +0200 Subject: [PATCH] Implement CSV import and conversion to contestants --- app/controllers/contestants_controller.rb | 49 ++++++++++++++++--- app/lib/forms.rb | 14 ++++++ app/models/csv_import.rb | 17 ++++--- app/policies/contest_policy.rb | 12 +++++ app/views/contestants/convert_csv.html.slim | 40 +++++++++++++++ config/locales/en.yml | 19 ++++++- config/locales/fr.yml | 17 +++++++ config/routes.rb | 4 +- ...0250517131707_add_content_to_csv_import.rb | 5 ++ db/schema.rb | 3 +- spec/factories/csv_imports.rb | 1 + spec/models/csv_import_spec.rb | 1 + 12 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 app/lib/forms.rb create mode 100644 app/views/contestants/convert_csv.html.slim create mode 100644 db/migrate/20250517131707_add_content_to_csv_import.rb diff --git a/app/controllers/contestants_controller.rb b/app/controllers/contestants_controller.rb index c90effd..2c2bded 100644 --- a/app/controllers/contestants_controller.rb +++ b/app/controllers/contestants_controller.rb @@ -45,15 +45,48 @@ class ContestantsController < ApplicationController def import authorize @contest - if params[:csv_import] - @csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator)) - if @csv_import.save - @csv_import = CsvImport.new - else - render :import, status: :unprocessable_entity - end + @csv_import = CsvImport.new + end + + def upload_csv + authorize @contest + + @csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator)) + if @csv_import.save + redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}" else - @csv_import = CsvImport.new + render :import, status: :unprocessable_entity + end + end + + def convert_csv + authorize @contest + + @csv_import = CsvImport.find(params[:id]) + @content = JSON.parse(@csv_import.content) + @form = Forms::CsvConversionForm.new + end + + def finalize_import + authorize @contest + + @csv_import = CsvImport.find(params[:id]) + @content = JSON.parse(@csv_import.content) + all_params = params.require(:forms_csv_conversion_form) + @form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column)) + if @form.valid? + @content.each_with_index do |row, i| + if all_params["row_#{i}".to_sym] == "1" + if @form.email_column == -1 + Contestant.create(name: row[@form.name_column], contest: @contest) + else + Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest) + end + end + end + redirect_to contest_path(@contest) + else + render :convert_csv, status: :unprocessable_entity end end diff --git a/app/lib/forms.rb b/app/lib/forms.rb new file mode 100644 index 0000000..3040077 --- /dev/null +++ b/app/lib/forms.rb @@ -0,0 +1,14 @@ +module Forms + class CsvConversionForm + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations::Callbacks + include ActiveRecord::Transactions + + attribute :name_column, :integer + attribute :email_column, :integer + + validates :name_column, presence: true + validates_numericality_of :name_column, greater_than: -1 + end +end diff --git a/app/models/csv_import.rb b/app/models/csv_import.rb index 45be3aa..71844f7 100644 --- a/app/models/csv_import.rb +++ b/app/models/csv_import.rb @@ -3,23 +3,26 @@ # Table name: csv_imports # # id :integer not null, primary key -# separator :integer not null +# content :string not null +# separator :string not null # created_at :datetime not null # updated_at :datetime not null # class CsvImport < ApplicationRecord - enum :separator, { comma: ",", semicolon: ";" }, default: :comma + enum :separator, { comma: ",", semicolon: ";" }, suffix: true, default: :comma has_one_attached :file validates :file, presence: true validate :acceptable_csv, on: :create + before_save :read_csv + def acceptable_csv return unless file.attached? - if file.blob.byte_size > 20 - errors.add(:file, "this csv file is too large, it must be under 20MB") + if file.blob.byte_size > 5 * 1024 * 1024 + errors.add(:file, "this csv file is too large, it must be under 5MB") return end @@ -31,14 +34,16 @@ class CsvImport < ApplicationRecord begin csv = CSV.read(attachment_changes["file"].attachable.path, col_sep: separator) - logger = Logger.new(STDOUT) - logger.info(csv) errors.add(:file, :empty) if csv.count < 1 || (csv.count == 1 && csv[0].count == 1 && csv[0][0] == "") rescue CSV::MalformedCSVError => e errors.add(:file, e.message) end end + def read_csv + self.content = JSON.dump(CSV.read(attachment_changes["file"].attachable.path, col_sep: separator_for_database)) + end + def options_for_separator keys = self.class.separators.keys keys.map(&:humanize).zip(keys).to_h diff --git a/app/policies/contest_policy.rb b/app/policies/contest_policy.rb index c6ee246..3ffa791 100644 --- a/app/policies/contest_policy.rb +++ b/app/policies/contest_policy.rb @@ -19,10 +19,18 @@ class ContestPolicy < ApplicationPolicy record.user.id == user.id || user.admin? end + def convert_csv? + record.user.id == user.id || user.admin? + end + def edit? record.user.id == user.id || user.admin? end + def finalize_import? + record.user.id == user.id || user.admin? + end + def update? record.user.id == user.id || user.admin? end @@ -38,4 +46,8 @@ class ContestPolicy < ApplicationPolicy def scoreboard? true end + + def upload_csv? + record.user.id == user.id || user.admin? + end end diff --git a/app/views/contestants/convert_csv.html.slim b/app/views/contestants/convert_csv.html.slim new file mode 100644 index 0000000..7425521 --- /dev/null +++ b/app/views/contestants/convert_csv.html.slim @@ -0,0 +1,40 @@ += form_with model: @form, url: "/contests/#{@contest.id}/import/#{@csv_import.id}" do |form| + + .row.mb-3 + .col + .form-floating + = form.select :name_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select" + = form.label :name_column + = t("contestants.import.name_column") + + .row.mb-3 + .col + .form-floating + = form.select :email_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select" + = form.label :email_column + = t("contestants.import.email_column") + + .row.g-3 + .col + table.table.table-striped.table-hover + thead + tr + - @content[0].each_with_index do |_, i| + th scope="col" + = t("helpers.field") + "_#{i}" + th scope="col" + = t("contestants.import.import_column") + tbody + - @content.each_with_index do |row, i| + tr scope="row" + - row.each do |value| + td + = value + td + .form-check.form-switch + = form.check_box "row_#{i}".to_sym, class: "form-check-input", checked: true + + .row.g-3 + .col + = form.submit t("helpers.buttons.confirm"), class: "btn btn-primary" + diff --git a/config/locales/en.yml b/config/locales/en.yml index 8f0e6a4..f825cdb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,6 +28,14 @@ # enabled: "ON" en: + activemodel: + errors: + models: + forms/csv_conversion_form: + attributes: + name_column: + blank: "Participant names are required" + greater_than: "Participant names are required" activerecord: attributes: contest: @@ -78,11 +86,18 @@ en: add_puzzle: "Add puzzle" public_scoreboard: "Public scoreboard: " contestants: + convert_csv: + title: "Import participants" edit: title: "Participant" team_title: "Teams" + finalize_import: + title: "Import participants" import: - title: "Import participants from a CSV file" + email_column: "Participant email" + import_column: "Import?" + name_column: "Participant name" + title: "Import participants" new: title: "New participant" team_title: "New team" @@ -94,9 +109,11 @@ en: helpers: buttons: add: "Add" + confirm: "Confirm" create: "Create" import: "CSV Import" save: "Save" + field: "Field" messages: convert: title: "Convert message into completion" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 589063b..d5c7bdb 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1,4 +1,12 @@ fr: + activemodel: + errors: + models: + forms/csv_conversion_form: + attributes: + name_column: + blank: "Choisir une colonne pour les noms des participant.e.s est nécessaire" + greater_than: "Choisir une colonne pour les noms des participant.e.s est nécessaire" activerecord: attributes: contest: @@ -49,10 +57,17 @@ fr: add_puzzle: "Ajouter un puzzle" public_scoreboard: "Classement public : " contestants: + convert_csv: + title: "Importer des participant.e.s" edit: title: "Participant.e" team_title: "Équipe" + finalize_import: + title: "Importer des participant.e.s" import: + email_column: "Email des participant.e.s" + import_column: "Importer ?" + name_column: "Noms des participant.e.s" title: "Importer des participant.e.s" new: title: "Nouveau.elle participant.e" @@ -65,9 +80,11 @@ fr: helpers: buttons: add: "Ajouter" + confirm: "Confirmer" create: "Créer" import: "Importer un CSV" save: "Modifier" + field: "Champ" messages: convert: title: "Conversion d'un message en complétion" diff --git a/config/routes.rb b/config/routes.rb index 5d9a6f5..80e3341 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,9 @@ Rails.application.routes.draw do get "convert", to: "messages#convert" end get "import", to: "contestants#import" - post "import", to: "contestants#import" + post "import", to: "contestants#upload_csv" + get "import/:id", to: "contestants#convert_csv" + post "import/:id", to: "contestants#finalize_import" end resources :passwords, param: :token resource :session diff --git a/db/migrate/20250517131707_add_content_to_csv_import.rb b/db/migrate/20250517131707_add_content_to_csv_import.rb new file mode 100644 index 0000000..f98004f --- /dev/null +++ b/db/migrate/20250517131707_add_content_to_csv_import.rb @@ -0,0 +1,5 @@ +class AddContentToCsvImport < ActiveRecord::Migration[8.0] + def change + add_column :csv_imports, :content, :string, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 653d717..dee96f3 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[8.0].define(version: 2025_05_17_083830) do +ActiveRecord::Schema[8.0].define(version: 2025_05_17_131707) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -79,6 +79,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_17_083830) do t.string "separator", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "content", null: false end create_table "friendly_id_slugs", force: :cascade do |t| diff --git a/spec/factories/csv_imports.rb b/spec/factories/csv_imports.rb index 4e2142f..d23f5cb 100644 --- a/spec/factories/csv_imports.rb +++ b/spec/factories/csv_imports.rb @@ -3,6 +3,7 @@ # Table name: csv_imports # # id :integer not null, primary key +# content :string not null # separator :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/spec/models/csv_import_spec.rb b/spec/models/csv_import_spec.rb index 46ea38d..08a622a 100644 --- a/spec/models/csv_import_spec.rb +++ b/spec/models/csv_import_spec.rb @@ -3,6 +3,7 @@ # Table name: csv_imports # # id :integer not null, primary key +# content :string not null # separator :string not null # created_at :datetime not null # updated_at :datetime not null