diff --git a/Gemfile b/Gemfile index e83784d..9195796 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,7 @@ gem "slim" gem "dartsass-rails" gem "bootstrap", "~> 5.3.3" gem "friendly_id", "~> 5.5.0" +gem "csv" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem diff --git a/Gemfile.lock b/Gemfile.lock index eebc7b2..b7ce3e7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -101,6 +101,7 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) + csv (3.3.4) dartsass-rails (0.5.1) railties (>= 6.0.0) sass-embedded (~> 1.63) @@ -432,6 +433,7 @@ DEPENDENCIES bootstrap (~> 5.3.3) brakeman capybara + csv dartsass-rails debug factory_bot_rails diff --git a/app/controllers/contestants_controller.rb b/app/controllers/contestants_controller.rb index 5422292..c90effd 100644 --- a/app/controllers/contestants_controller.rb +++ b/app/controllers/contestants_controller.rb @@ -42,6 +42,21 @@ class ContestantsController < ApplicationController redirect_to contest_path(@contest) end + 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 + else + @csv_import = CsvImport.new + end + end + private def set_contest diff --git a/app/models/csv_import.rb b/app/models/csv_import.rb new file mode 100644 index 0000000..45be3aa --- /dev/null +++ b/app/models/csv_import.rb @@ -0,0 +1,46 @@ +# == Schema Information +# +# Table name: csv_imports +# +# id :integer not null, primary key +# separator :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +class CsvImport < ApplicationRecord + enum :separator, { comma: ",", semicolon: ";" }, default: :comma + + has_one_attached :file + + validates :file, presence: true + validate :acceptable_csv, on: :create + + 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") + return + end + + if file.content_type != "text/csv" + errors.add(:file, :not_a_csv_file) + return + end + + 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 options_for_separator + keys = self.class.separators.keys + keys.map(&:humanize).zip(keys).to_h + end +end diff --git a/app/policies/contest_policy.rb b/app/policies/contest_policy.rb index d309f97..c6ee246 100644 --- a/app/policies/contest_policy.rb +++ b/app/policies/contest_policy.rb @@ -31,6 +31,10 @@ class ContestPolicy < ApplicationPolicy record.user.id == user.id || user.admin? end + def import? + record.user.id == user.id || user.admin? + end + def scoreboard? true end diff --git a/app/views/contestants/import.html.slim b/app/views/contestants/import.html.slim new file mode 100644 index 0000000..4e95c23 --- /dev/null +++ b/app/views/contestants/import.html.slim @@ -0,0 +1,19 @@ += form_with(model: @csv_import, url: contest_import_path(@contest), html: { novalidate: true }) do |form| + .row.g-3 + .col + .mb-3 + .form-floating + = form.file_field :file, class: "form-control", accept: ".csv, text/csv" + = form.label :file + + .row.g-3 + .col + .mb-3 + .form-floating + = form.select :separator, @csv_import.options_for_separator, {}, class: "form-select" + = form.label :separator + + .row.g-3 + .col + .mb-3 + = form.submit t("helpers.buttons.import"), class: "btn btn-primary" \ No newline at end of file diff --git a/app/views/contests/show.html.slim b/app/views/contests/show.html.slim index 6e4b7ab..0e2021b 100644 --- a/app/views/contests/show.html.slim +++ b/app/views/contests/show.html.slim @@ -76,6 +76,8 @@ = t("contestants.plural").capitalize a.ms-3.btn.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px" | + #{t("helpers.buttons.add")} + a.ms-3.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px" + | #{t("helpers.buttons.import")} table.table.table-striped.table-hover thead tr diff --git a/config/locales/en.yml b/config/locales/en.yml index d4dd454..8f0e6a4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -49,6 +49,12 @@ en: invalid: "Allowed formats: xx:xx:xx, x:xx:xx, xx:xx, x:xx" puzzle_id: taken: "This contestant has already completed this puzzle" + csv_import: + attributes: + file: + blank: "No file selected" + empty: "This file is empty" + not_a_csv_file: "it must be a CSV file" completions: edit: title: "Edit completion" @@ -75,6 +81,8 @@ en: edit: title: "Participant" team_title: "Teams" + import: + title: "Import participants from a CSV file" new: title: "New participant" team_title: "New team" @@ -87,6 +95,7 @@ en: buttons: add: "Add" create: "Create" + import: "CSV Import" save: "Save" messages: convert: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 1b284a3..589063b 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -20,6 +20,12 @@ fr: invalid: "Formats autorisés: xx:xx:xx, x:xx:xx, xx:xx, x:xx" puzzle_id: taken: "Ce.tte participant.e a déjà complété ce puzzle" + csv_import: + attributes: + file: + blank: "Aucun fichier sélectionné" + empty: "Ce fichier est vide" + not_a_csv_file: "Le fichier doit être au format CSV" completions: edit: title: "Modifier la complétion" @@ -46,6 +52,8 @@ fr: edit: title: "Participant.e" team_title: "Équipe" + import: + title: "Importer des participant.e.s" new: title: "Nouveau.elle participant.e" team_title: "Nouvelle équipe" @@ -58,6 +66,7 @@ fr: buttons: add: "Ajouter" create: "Créer" + import: "Importer un CSV" save: "Modifier" messages: convert: diff --git a/config/routes.rb b/config/routes.rb index 11cd9ff..5d9a6f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,8 @@ Rails.application.routes.draw do resources :messages, only: :destroy do get "convert", to: "messages#convert" end + get "import", to: "contestants#import" + post "import", to: "contestants#import" end resources :passwords, param: :token resource :session diff --git a/db/migrate/20250517083830_create_csv_imports.rb b/db/migrate/20250517083830_create_csv_imports.rb new file mode 100644 index 0000000..f55320a --- /dev/null +++ b/db/migrate/20250517083830_create_csv_imports.rb @@ -0,0 +1,9 @@ +class CreateCsvImports < ActiveRecord::Migration[8.0] + def change + create_table :csv_imports do |t| + t.string :separator, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d91116d..653d717 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_15_062154) do +ActiveRecord::Schema[8.0].define(version: 2025_05_17_083830) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -75,6 +75,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_15_062154) do t.index ["user_id"], name: "index_contests_on_user_id" end + create_table "csv_imports", force: :cascade do |t| + t.string "separator", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "friendly_id_slugs", force: :cascade do |t| t.string "slug", null: false t.integer "sluggable_id", null: false diff --git a/spec/factories/csv_imports.rb b/spec/factories/csv_imports.rb new file mode 100644 index 0000000..4e2142f --- /dev/null +++ b/spec/factories/csv_imports.rb @@ -0,0 +1,14 @@ +# == Schema Information +# +# Table name: csv_imports +# +# id :integer not null, primary key +# separator :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +FactoryBot.define do + factory :csv_import do + separator { 1 } + end +end diff --git a/spec/models/csv_import_spec.rb b/spec/models/csv_import_spec.rb new file mode 100644 index 0000000..46ea38d --- /dev/null +++ b/spec/models/csv_import_spec.rb @@ -0,0 +1,14 @@ +# == Schema Information +# +# Table name: csv_imports +# +# id :integer not null, primary key +# separator :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +require 'rails_helper' + +RSpec.describe CsvImport, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end