From 94e725d20a04201e873ff7c8ed4a3717d70089a7 Mon Sep 17 00:00:00 2001 From: sto Date: Thu, 20 Nov 2025 16:59:30 +0100 Subject: [PATCH] Add judges codes --- app/controllers/completions_controller.rb | 2 +- app/controllers/contestants_controller.rb | 18 +- app/controllers/contests_controller.rb | 2 +- app/models/completion.rb | 10 +- app/models/contest.rb | 1 + app/models/contestant.rb | 4 - app/views/completions/_form.html.slim | 185 ++++++++++-------- .../contestants/generate_qrcodes.html.slim | 34 ++-- .../contests/settings_general_edit.html.slim | 6 + config/locales/en.yml | 10 + config/locales/fr.yml | 10 + .../20251120150744_add_code_to_contest.rb | 5 + .../20251120152211_add_code_to_completion.rb | 5 + db/schema.rb | 4 +- spec/factories/contests.rb | 1 + 15 files changed, 184 insertions(+), 113 deletions(-) create mode 100644 db/migrate/20251120150744_add_code_to_contest.rb create mode 100644 db/migrate/20251120152211_add_code_to_completion.rb diff --git a/app/controllers/completions_controller.rb b/app/controllers/completions_controller.rb index cee64bd..936f287 100644 --- a/app/controllers/completions_controller.rb +++ b/app/controllers/completions_controller.rb @@ -24,7 +24,7 @@ class CompletionsController < ApplicationController authorize @contest @completion = Completion.new(completion_params) - @completion.contest = @contet + @completion.contest = @contest if @completion.save extend_completions!(@completion.contestant) if @contestant && !params[:completion].key?(:message_id) diff --git a/app/controllers/contestants_controller.rb b/app/controllers/contestants_controller.rb index 84d0499..51b14f1 100644 --- a/app/controllers/contestants_controller.rb +++ b/app/controllers/contestants_controller.rb @@ -123,12 +123,6 @@ class ContestantsController < ApplicationController def generate_qrcodes authorize @contest - @contest.contestants.each do |contestant| - if !contestant.qrcode.present? - contestant.public_generate_qrcode - contestant.save - end - end @contestants = @contest.contestants.sort_by { |contestant| contestant.name } end @@ -140,6 +134,7 @@ class ContestantsController < ApplicationController not_found and return end @contest = @contestant.contest + I18n.locale = @contest.lang @puzzles = @contest.puzzles @completion = Completion.new @completion.completed = true @@ -156,16 +151,24 @@ class ContestantsController < ApplicationController not_found and return end @contest = @contestant.contest + I18n.locale = @contest.lang @completion = Completion.new(completion_params) @completion.contest = @contest @completion.contestant = @contestant + if !@completion.code.present? + to_modify = true + @completion.code = "incorrect-xZy" + end if @completion.save extend_completions!(@completion.contestant) redirect_to "/public/p/#{params[:token]}/updated" else @puzzles = @contest.puzzles @public = true + if to_modify + @completion.code = nil + end render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:token]}" }, status: :unprocessable_entity end end @@ -177,6 +180,7 @@ class ContestantsController < ApplicationController if !@contestant not_found and return end + I18n.locale = @contestant.contest.lang end private @@ -219,6 +223,6 @@ class ContestantsController < ApplicationController end def completion_params - params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id ]) + params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id, :code ]) end end diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index d2bb182..f161b47 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -210,7 +210,7 @@ class ContestsController < ApplicationController end def settings_general_params - params.expect(contest: [ :lang, :name, :duration, :public, :ranking_mode, :team, :allow_registration ]) + params.expect(contest: [ :lang, :name, :duration, :public, :ranking_mode, :team, :allow_registration, :code ]) end def settings_offline_params diff --git a/app/models/completion.rb b/app/models/completion.rb index f632798..df8d7ff 100644 --- a/app/models/completion.rb +++ b/app/models/completion.rb @@ -3,6 +3,7 @@ # Table name: completions # # id :integer not null, primary key +# code :string # completed :boolean # display_relative_time :string # display_time_from_start :string @@ -50,10 +51,11 @@ class Completion < ApplicationRecord validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 } validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? } validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? } + validate :contest_code_is_correct, if: -> { code.present? } def remaining_pieces_is_correct if self.remaining_pieces > self.puzzle.pieces - errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle") + errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large")) end end @@ -92,4 +94,10 @@ class Completion < ApplicationRecord self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second) end end + + def contest_code_is_correct + if self.code != self.contest.code + errors.add(:code, I18n.t("activerecord.errors.models.completion.attributes.code.mismatch")) + end + end end diff --git a/app/models/contest.rb b/app/models/contest.rb index 9f17ca2..7a60dbd 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -4,6 +4,7 @@ # # id :integer not null, primary key # allow_registration :boolean default(FALSE) +# code :string # duration :string # duration_seconds :integer # lang :string default("en") diff --git a/app/models/contestant.rb b/app/models/contestant.rb index 16e3a20..0a895ec 100644 --- a/app/models/contestant.rb +++ b/app/models/contestant.rb @@ -43,10 +43,6 @@ class Contestant < ApplicationRecord end end - def public_generate_qrcode - self.generate_qrcode - end - private def initialize_time_seconds_if_empty diff --git a/app/views/completions/_form.html.slim b/app/views/completions/_form.html.slim index f03da20..2ca8e1a 100644 --- a/app/views/completions/_form.html.slim +++ b/app/views/completions/_form.html.slim @@ -1,88 +1,101 @@ -= form_with model: completion, url: url, method: method do |form| - - if @message - = form.hidden_field :message_id, value: @message.id - .row.mb-3 +- if @public && @puzzles.length == @contestant.completions.length + h4 + = t("completions.form.validate_name", name: @contestant.name) + .mt-3.alert.alert-warning + = t("completions.form.all_finished", name: @contestant.name) +- else + = form_with model: completion, url: url, method: method do |form| + - if @message + = form.hidden_field :message_id, value: @message.id + .row.mb-3 + .col + h4 = t("messages.singular").capitalize + .alert.alert-secondary + b + = @message.author + br + = @message.text + .row.mb-2 .col - h4 = t("messages.singular").capitalize - .alert.alert-secondary - b - = @message.author - br - = @message.text - .row.mb-2 - .col - h4 - - if @public - = t("completions.form.validate_name", name: @contestant.name) - - else - = t("completions.singular").capitalize - - if @contestants.present? - .row.mb-3 - .col - .form-floating - = form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select" - = form.label :contestant_id - - if @closest_contestant - javascript: - el = document.querySelector('select[name="completion[contestant_id]"]'); - el.value = "#{@closest_contestant.id}" - - if @puzzles.size > 1 - .row.mb-3 - .col - .form-floating - = form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select" - = form.label :puzzle_id - - elsif @puzzles.size == 1 - = form.hidden_field :puzzle_id, value: @puzzles.first.id - - else - = form.hidden_field :puzzle_id - .row.mb-3 - .col - .form-check.form-switch - = form.check_box :completed, class: "form-check-input" - = form.label :completed + h4 + - if @public + = t("completions.form.validate_name", name: @contestant.name) + - else + = t("completions.singular").capitalize + - if @contestants.present? + .row.mb-3 + .col + .form-floating + = form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select" + = form.label :contestant_id + - if @closest_contestant javascript: - completedEl = document.getElementById('completion_completed'); - completedEl.addEventListener('change', (e) => { - const timeEl = document.getElementById('time'); - const missingPiecesEl = document.getElementById('missing_pieces'); - const remainingPiecesEl = document.getElementById('remaining_pieces'); - if (e.target.checked) { - timeEl.value = '#{@completion.display_time_from_start}'; - missingPiecesEl.style.display = 'block'; - remainingPiecesEl.style.display = 'none'; - } else { - timeEl.value = '#{display_time(@contest.duration_seconds)}'; - missingPiecesEl.style.display = 'none'; - remainingPiecesEl.style.display = 'block'; - } - }) - .row.mb-3 - .col - .form-floating - = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time" - = form.label :display_time_from_start, class: "required" - .row.mb-3 id="missing_pieces" - .col - .form-floating - = form.text_field :missing_pieces, autocomplete: "off", class: "form-control" - = form.label :missing_pieces - .row.mb-3 id="remaining_pieces" style="display: none;" - .col - .form-floating - = form.text_field :remaining_pieces, autocomplete: "off", class: "form-control" - = form.label :remaining_pieces - javascript: - completedEl = document.getElementById('completion_completed'); - missingPiecesEl = document.getElementById('missing_pieces'); - remainingPiecesEl = document.getElementById('remaining_pieces'); - if (completedEl.checked) { - missingPiecesEl.style.display = 'block'; - remainingPiecesEl.style.display = 'none'; - } else { - missingPiecesEl.style.display = 'none'; - remainingPiecesEl.style.display = 'block'; - } - .row - .col - = form.submit submit_text, class: "btn btn-primary" \ No newline at end of file + el = document.querySelector('select[name="completion[contestant_id]"]'); + el.value = "#{@closest_contestant.id}" + - if @puzzles.size > 1 + .row.mb-3 + .col + .form-floating + = form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select" + = form.label :puzzle_id + - elsif @puzzles.size == 1 + = form.hidden_field :puzzle_id, value: @puzzles.first.id + - else + = form.hidden_field :puzzle_id + .row.mb-3 + .col + .form-check.form-switch + = form.check_box :completed, class: "form-check-input" + = form.label :completed + javascript: + completedEl = document.getElementById('completion_completed'); + completedEl.addEventListener('change', (e) => { + const timeEl = document.getElementById('time'); + const missingPiecesEl = document.getElementById('missing_pieces'); + const remainingPiecesEl = document.getElementById('remaining_pieces'); + if (e.target.checked) { + timeEl.value = '#{@completion.display_time_from_start}'; + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + timeEl.value = '#{display_time(@contest.duration_seconds)}'; + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } + }) + .row.mb-3 + .col + .form-floating + = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time" + = form.label :display_time_from_start, class: "required" + .row.mb-3 id="missing_pieces" + .col + .form-floating + = form.text_field :missing_pieces, autocomplete: "off", class: "form-control" + = form.label :missing_pieces + .row.mb-3 id="remaining_pieces" style="display: none;" + .col + .form-floating + = form.text_field :remaining_pieces, autocomplete: "off", class: "form-control" + = form.label :remaining_pieces + javascript: + completedEl = document.getElementById('completion_completed'); + missingPiecesEl = document.getElementById('missing_pieces'); + remainingPiecesEl = document.getElementById('remaining_pieces'); + if (completedEl.checked) { + missingPiecesEl.style.display = 'block'; + remainingPiecesEl.style.display = 'none'; + } else { + missingPiecesEl.style.display = 'none'; + remainingPiecesEl.style.display = 'block'; + } + - if @public + .row.mb-3 + .col + .form-floating + = form.text_field :code, autocomplete: "off", class: "form-control" + = form.label :code + = t("completions.form.code") + .row + .col + = form.submit submit_text, class: "btn btn-primary" \ No newline at end of file diff --git a/app/views/contestants/generate_qrcodes.html.slim b/app/views/contestants/generate_qrcodes.html.slim index d6ba639..da786a9 100644 --- a/app/views/contestants/generate_qrcodes.html.slim +++ b/app/views/contestants/generate_qrcodes.html.slim @@ -1,12 +1,22 @@ -.row.mb-4 style="height: calc(100vh - 280px)" - .col.d-flex.flex-column style="height: 100%" - .d-flex.flex-column style="overflow-y: auto" - - for row in 0..((@contestants.length - 1) / 4) - .mt-4.d-flex.flex-row - - for col in 0..3 - - if row * 4 + col < @contestants.length - .d-flex.flex-column.ms-5 style="align-items: center" - = @contestants[row * 4 + col].name - - if @contestants[row * 4 + col].qrcode.present? - .mt-1 style="width: 200px; height: 200px;" - = @contestants[row * 4 + col].qrcode.html_safe \ No newline at end of file +- if @contest.code.present? + .row.mb-4 style="height: calc(100vh - 280px)" + .row + .col + .alert.alert-info + = t("contestants.generate_qrcodes.note") + .col.d-flex.flex-column style="height: 100%" + .d-flex.flex-column style="overflow-y: auto" + - for row in 0..((@contestants.length - 1) / 5) + .mt-4.d-flex.flex-row + - for col in 0..4 + - if row * 5 + col < @contestants.length + .d-flex.flex-column.ms-5 style="align-items: center" + = @contestants[row * 5 + col].name + - if @contestants[row * 5 + col].qrcode.present? + .mt-1 style="width: 180px; height: 180px;" + = @contestants[row * 5 + col].qrcode.html_safe +- else + .row + .col + .alert.alert-warning + = t("contestants.generate_qrcodes.no_code_note") \ No newline at end of file diff --git a/app/views/contests/settings_general_edit.html.slim b/app/views/contests/settings_general_edit.html.slim index 34515a6..210eac8 100644 --- a/app/views/contests/settings_general_edit.html.slim +++ b/app/views/contests/settings_general_edit.html.slim @@ -20,6 +20,12 @@ .form-floating = form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select" = form.label :ranking_mode + .row.mt-2.mb-3 + .col + .form-floating + = form.text_field :code, autocomplete: "off", class: "form-control" + = form.label :code, class: "required" + .form-text = t("activerecord.attributes.contest.code_description") .row.mt-4.mb-3 .col .form-check.form-switch diff --git a/config/locales/en.yml b/config/locales/en.yml index 815058b..0545bf1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -53,6 +53,8 @@ en: projected_time: Projected time remaining_pieces: Remaining pieces (not completed puzzle) contest: + code: Code for onsite judges + code_description: Only useful for onsite contests, if printing QR codes on tables duration: Duration duration_description: Format h:mm or hh:mm lang: Language for the public scoreboard @@ -103,6 +105,8 @@ en: models: completion: attributes: + code: + mismatch: "Wrong code" contestant_id: taken: "This contestant has already completed the puzzle" display_time_from_start: @@ -114,6 +118,7 @@ en: blank: This is required not_an_integer: This is not an integer not_a_number: This is not an integer + too_large: "Cannot be greater than the number of pieces for this puzzle" contest: attributes: duration: @@ -171,6 +176,8 @@ en: notice: Completion updated title: Edit completion form: + all_finished: "All puzzles were already completed by %{name}" + code: Judges code validate_name: "Validate a puzzle for %{name}" new: notice: Completion added @@ -226,6 +233,9 @@ en: team_title: Teams finalize_import: title: Import participants + generate_qrcodes: + note: These QR codes allow for judges to fill in results without the need of the organizer's account, for example by printing them and placing them on participant tables + no_code_note: Those can't be used until a code for judges has been set up in the general settings import: email_column: Participant email import_column: Import? diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f85c28b..8ca16a8 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -24,6 +24,8 @@ fr: projected_time: Temps projeté remaining_pieces: Pièces restantes (puzzle non fini) contest: + code: Code pour les organisateur.ice.s + code_description: Utile uniquement pour les concours en présentiel, si les QR codes sont imprimés et placés sur les tables duration: Durée duration_description: Format h:mm ou hh:mm lang: Langue pour le classement public @@ -74,6 +76,8 @@ fr: models: completion: attributes: + code: + mismatch: "Code non valide" contestant_id: taken: "Ce.tte participant.e a déjà complété le puzzle" display_time_from_start: @@ -85,6 +89,7 @@ fr: blank: Ce champ est obligatoire not_an_integer: Ce n'est pas un nombre entier not_a_number: Ce n'est pas un nombre entier + too_large: "Ne peut pas être plus grand que le nombre de pièces du puzzle" contest: attributes: duration: @@ -142,6 +147,8 @@ fr: notice: Complétion modifiée title: Modifier la complétion form: + all_finished: Tous les puzzles ont déjà été complétés par %{name} + code: Code organisateur.ice validate_name: "Valider un puzzle pour %{name}" new: notice: Complétion ajoutée @@ -197,6 +204,9 @@ fr: team_title: Équipe finalize_import: title: Importer des participant.e.s + generate_qrcodes: + note: Ces QR codes permettent, quand imprimés et placés sur les tables des participant.e.s, aux organisateur.ice.s de valider les temps de complétion des puzzles + no_code_note: Les codes ne seront générés qu'une fois un code pour les organisateur.ice.s défini dans les paramètres généraux import: email_column: Email des participant.e.s import_column: Importer ? diff --git a/db/migrate/20251120150744_add_code_to_contest.rb b/db/migrate/20251120150744_add_code_to_contest.rb new file mode 100644 index 0000000..3780b0b --- /dev/null +++ b/db/migrate/20251120150744_add_code_to_contest.rb @@ -0,0 +1,5 @@ +class AddCodeToContest < ActiveRecord::Migration[8.0] + def change + add_column :contests, :code, :string + end +end diff --git a/db/migrate/20251120152211_add_code_to_completion.rb b/db/migrate/20251120152211_add_code_to_completion.rb new file mode 100644 index 0000000..2df7f3d --- /dev/null +++ b/db/migrate/20251120152211_add_code_to_completion.rb @@ -0,0 +1,5 @@ +class AddCodeToCompletion < ActiveRecord::Migration[8.0] + def change + add_column :completions, :code, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 0bcabf5..b638e33 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_11_20_100813) do +ActiveRecord::Schema[8.0].define(version: 2025_11_20_152211) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -68,6 +68,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_20_100813) do t.integer "missing_pieces" t.boolean "completed" t.integer "projected_time" + t.string "code" t.index ["contest_id"], name: "index_completions_on_contest_id" t.index ["contestant_id"], name: "index_completions_on_contestant_id" t.index ["message_id"], name: "index_completions_on_message_id" @@ -101,6 +102,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_20_100813) do t.string "ranking_mode" t.string "duration" t.integer "duration_seconds" + t.string "code" t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["user_id"], name: "index_contests_on_user_id" end diff --git a/spec/factories/contests.rb b/spec/factories/contests.rb index 074468d..746f758 100644 --- a/spec/factories/contests.rb +++ b/spec/factories/contests.rb @@ -4,6 +4,7 @@ # # id :integer not null, primary key # allow_registration :boolean default(FALSE) +# code :string # duration :string # duration_seconds :integer # lang :string default("en")