Add judges codes
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 45s

This commit is contained in:
sto
2025-11-20 16:59:30 +01:00
parent b43a801e3c
commit 94e725d20a
15 changed files with 184 additions and 113 deletions

View File

@@ -24,7 +24,7 @@ class CompletionsController < ApplicationController
authorize @contest authorize @contest
@completion = Completion.new(completion_params) @completion = Completion.new(completion_params)
@completion.contest = @contet @completion.contest = @contest
if @completion.save if @completion.save
extend_completions!(@completion.contestant) extend_completions!(@completion.contestant)
if @contestant && !params[:completion].key?(:message_id) if @contestant && !params[:completion].key?(:message_id)

View File

@@ -123,12 +123,6 @@ class ContestantsController < ApplicationController
def generate_qrcodes def generate_qrcodes
authorize @contest 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 } @contestants = @contest.contestants.sort_by { |contestant| contestant.name }
end end
@@ -140,6 +134,7 @@ class ContestantsController < ApplicationController
not_found and return not_found and return
end end
@contest = @contestant.contest @contest = @contestant.contest
I18n.locale = @contest.lang
@puzzles = @contest.puzzles @puzzles = @contest.puzzles
@completion = Completion.new @completion = Completion.new
@completion.completed = true @completion.completed = true
@@ -156,16 +151,24 @@ class ContestantsController < ApplicationController
not_found and return not_found and return
end end
@contest = @contestant.contest @contest = @contestant.contest
I18n.locale = @contest.lang
@completion = Completion.new(completion_params) @completion = Completion.new(completion_params)
@completion.contest = @contest @completion.contest = @contest
@completion.contestant = @contestant @completion.contestant = @contestant
if !@completion.code.present?
to_modify = true
@completion.code = "incorrect-xZy"
end
if @completion.save if @completion.save
extend_completions!(@completion.contestant) extend_completions!(@completion.contestant)
redirect_to "/public/p/#{params[:token]}/updated" redirect_to "/public/p/#{params[:token]}/updated"
else else
@puzzles = @contest.puzzles @puzzles = @contest.puzzles
@public = true @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 render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:token]}" }, status: :unprocessable_entity
end end
end end
@@ -177,6 +180,7 @@ class ContestantsController < ApplicationController
if !@contestant if !@contestant
not_found and return not_found and return
end end
I18n.locale = @contestant.contest.lang
end end
private private
@@ -219,6 +223,6 @@ class ContestantsController < ApplicationController
end end
def completion_params 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
end end

View File

@@ -210,7 +210,7 @@ class ContestsController < ApplicationController
end end
def settings_general_params 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 end
def settings_offline_params def settings_offline_params

View File

@@ -3,6 +3,7 @@
# Table name: completions # Table name: completions
# #
# id :integer not null, primary key # id :integer not null, primary key
# code :string
# completed :boolean # completed :boolean
# display_relative_time :string # display_relative_time :string
# display_time_from_start :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 :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? } validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
validate :remaining_pieces_is_correct, 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 def remaining_pieces_is_correct
if self.remaining_pieces > self.puzzle.pieces 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
end end
@@ -92,4 +94,10 @@ class Completion < ApplicationRecord
self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second) self.projected_time = assembled_time + Integer(self.remaining_pieces.to_f / pieces_per_second)
end end
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 end

View File

@@ -4,6 +4,7 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# code :string
# duration :string # duration :string
# duration_seconds :integer # duration_seconds :integer
# lang :string default("en") # lang :string default("en")

View File

@@ -43,10 +43,6 @@ class Contestant < ApplicationRecord
end end
end end
def public_generate_qrcode
self.generate_qrcode
end
private private
def initialize_time_seconds_if_empty def initialize_time_seconds_if_empty

View File

@@ -1,88 +1,101 @@
= form_with model: completion, url: url, method: method do |form| - if @public && @puzzles.length == @contestant.completions.length
- if @message h4
= form.hidden_field :message_id, value: @message.id = t("completions.form.validate_name", name: @contestant.name)
.row.mb-3 .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 .col
h4 = t("messages.singular").capitalize h4
.alert.alert-secondary - if @public
b = t("completions.form.validate_name", name: @contestant.name)
= @message.author - else
br = t("completions.singular").capitalize
= @message.text - if @contestants.present?
.row.mb-2 .row.mb-3
.col .col
h4 .form-floating
- if @public = form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
= t("completions.form.validate_name", name: @contestant.name) = form.label :contestant_id
- else - if @closest_contestant
= 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
javascript: javascript:
completedEl = document.getElementById('completion_completed'); el = document.querySelector('select[name="completion[contestant_id]"]');
completedEl.addEventListener('change', (e) => { el.value = "#{@closest_contestant.id}"
const timeEl = document.getElementById('time'); - if @puzzles.size > 1
const missingPiecesEl = document.getElementById('missing_pieces'); .row.mb-3
const remainingPiecesEl = document.getElementById('remaining_pieces'); .col
if (e.target.checked) { .form-floating
timeEl.value = '#{@completion.display_time_from_start}'; = form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
missingPiecesEl.style.display = 'block'; = form.label :puzzle_id
remainingPiecesEl.style.display = 'none'; - elsif @puzzles.size == 1
} else { = form.hidden_field :puzzle_id, value: @puzzles.first.id
timeEl.value = '#{display_time(@contest.duration_seconds)}'; - else
missingPiecesEl.style.display = 'none'; = form.hidden_field :puzzle_id
remainingPiecesEl.style.display = 'block'; .row.mb-3
} .col
}) .form-check.form-switch
.row.mb-3 = form.check_box :completed, class: "form-check-input"
.col = form.label :completed
.form-floating javascript:
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time" completedEl = document.getElementById('completion_completed');
= form.label :display_time_from_start, class: "required" completedEl.addEventListener('change', (e) => {
.row.mb-3 id="missing_pieces" const timeEl = document.getElementById('time');
.col const missingPiecesEl = document.getElementById('missing_pieces');
.form-floating const remainingPiecesEl = document.getElementById('remaining_pieces');
= form.text_field :missing_pieces, autocomplete: "off", class: "form-control" if (e.target.checked) {
= form.label :missing_pieces timeEl.value = '#{@completion.display_time_from_start}';
.row.mb-3 id="remaining_pieces" style="display: none;" missingPiecesEl.style.display = 'block';
.col remainingPiecesEl.style.display = 'none';
.form-floating } else {
= form.text_field :remaining_pieces, autocomplete: "off", class: "form-control" timeEl.value = '#{display_time(@contest.duration_seconds)}';
= form.label :remaining_pieces missingPiecesEl.style.display = 'none';
javascript: remainingPiecesEl.style.display = 'block';
completedEl = document.getElementById('completion_completed'); }
missingPiecesEl = document.getElementById('missing_pieces'); })
remainingPiecesEl = document.getElementById('remaining_pieces'); .row.mb-3
if (completedEl.checked) { .col
missingPiecesEl.style.display = 'block'; .form-floating
remainingPiecesEl.style.display = 'none'; = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
} else { = form.label :display_time_from_start, class: "required"
missingPiecesEl.style.display = 'none'; .row.mb-3 id="missing_pieces"
remainingPiecesEl.style.display = 'block'; .col
} .form-floating
.row = form.text_field :missing_pieces, autocomplete: "off", class: "form-control"
.col = form.label :missing_pieces
= form.submit submit_text, class: "btn btn-primary" .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"

View File

@@ -1,12 +1,22 @@
.row.mb-4 style="height: calc(100vh - 280px)" - if @contest.code.present?
.col.d-flex.flex-column style="height: 100%" .row.mb-4 style="height: calc(100vh - 280px)"
.d-flex.flex-column style="overflow-y: auto" .row
- for row in 0..((@contestants.length - 1) / 4) .col
.mt-4.d-flex.flex-row .alert.alert-info
- for col in 0..3 = t("contestants.generate_qrcodes.note")
- if row * 4 + col < @contestants.length .col.d-flex.flex-column style="height: 100%"
.d-flex.flex-column.ms-5 style="align-items: center" .d-flex.flex-column style="overflow-y: auto"
= @contestants[row * 4 + col].name - for row in 0..((@contestants.length - 1) / 5)
- if @contestants[row * 4 + col].qrcode.present? .mt-4.d-flex.flex-row
.mt-1 style="width: 200px; height: 200px;" - for col in 0..4
= @contestants[row * 4 + col].qrcode.html_safe - 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")

View File

@@ -20,6 +20,12 @@
.form-floating .form-floating
= form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select" = form.select :ranking_mode, Ranking::AVAILABLE_RANKING_MODES.map { |mode| [ mode[:name], mode[:id] ] }, {}, class: "form-select"
= form.label :ranking_mode = 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 .row.mt-4.mb-3
.col .col
.form-check.form-switch .form-check.form-switch

View File

@@ -53,6 +53,8 @@ en:
projected_time: Projected time projected_time: Projected time
remaining_pieces: Remaining pieces (not completed puzzle) remaining_pieces: Remaining pieces (not completed puzzle)
contest: contest:
code: Code for onsite judges
code_description: Only useful for onsite contests, if printing QR codes on tables
duration: Duration duration: Duration
duration_description: Format h:mm or hh:mm duration_description: Format h:mm or hh:mm
lang: Language for the public scoreboard lang: Language for the public scoreboard
@@ -103,6 +105,8 @@ en:
models: models:
completion: completion:
attributes: attributes:
code:
mismatch: "Wrong code"
contestant_id: contestant_id:
taken: "This contestant has already completed the puzzle" taken: "This contestant has already completed the puzzle"
display_time_from_start: display_time_from_start:
@@ -114,6 +118,7 @@ en:
blank: This is required blank: This is required
not_an_integer: This is not an integer not_an_integer: This is not an integer
not_a_number: 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: contest:
attributes: attributes:
duration: duration:
@@ -171,6 +176,8 @@ en:
notice: Completion updated notice: Completion updated
title: Edit completion title: Edit completion
form: form:
all_finished: "All puzzles were already completed by %{name}"
code: Judges code
validate_name: "Validate a puzzle for %{name}" validate_name: "Validate a puzzle for %{name}"
new: new:
notice: Completion added notice: Completion added
@@ -226,6 +233,9 @@ en:
team_title: Teams team_title: Teams
finalize_import: finalize_import:
title: Import participants 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: import:
email_column: Participant email email_column: Participant email
import_column: Import? import_column: Import?

View File

@@ -24,6 +24,8 @@ fr:
projected_time: Temps projeté projected_time: Temps projeté
remaining_pieces: Pièces restantes (puzzle non fini) remaining_pieces: Pièces restantes (puzzle non fini)
contest: 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: Durée
duration_description: Format h:mm ou hh:mm duration_description: Format h:mm ou hh:mm
lang: Langue pour le classement public lang: Langue pour le classement public
@@ -74,6 +76,8 @@ fr:
models: models:
completion: completion:
attributes: attributes:
code:
mismatch: "Code non valide"
contestant_id: contestant_id:
taken: "Ce.tte participant.e a déjà complété le puzzle" taken: "Ce.tte participant.e a déjà complété le puzzle"
display_time_from_start: display_time_from_start:
@@ -85,6 +89,7 @@ fr:
blank: Ce champ est obligatoire blank: Ce champ est obligatoire
not_an_integer: Ce n'est pas un nombre entier not_an_integer: Ce n'est pas un nombre entier
not_a_number: 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: contest:
attributes: attributes:
duration: duration:
@@ -142,6 +147,8 @@ fr:
notice: Complétion modifiée notice: Complétion modifiée
title: Modifier la complétion title: Modifier la complétion
form: 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}" validate_name: "Valider un puzzle pour %{name}"
new: new:
notice: Complétion ajoutée notice: Complétion ajoutée
@@ -197,6 +204,9 @@ fr:
team_title: Équipe team_title: Équipe
finalize_import: finalize_import:
title: Importer des participant.e.s 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: import:
email_column: Email des participant.e.s email_column: Email des participant.e.s
import_column: Importer ? import_column: Importer ?

View File

@@ -0,0 +1,5 @@
class AddCodeToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :code, :string
end
end

View File

@@ -0,0 +1,5 @@
class AddCodeToCompletion < ActiveRecord::Migration[8.0]
def change
add_column :completions, :code, :string
end
end

4
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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.integer "missing_pieces"
t.boolean "completed" t.boolean "completed"
t.integer "projected_time" t.integer "projected_time"
t.string "code"
t.index ["contest_id"], name: "index_completions_on_contest_id" t.index ["contest_id"], name: "index_completions_on_contest_id"
t.index ["contestant_id"], name: "index_completions_on_contestant_id" t.index ["contestant_id"], name: "index_completions_on_contestant_id"
t.index ["message_id"], name: "index_completions_on_message_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 "ranking_mode"
t.string "duration" t.string "duration"
t.integer "duration_seconds" t.integer "duration_seconds"
t.string "code"
t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["slug"], name: "index_contests_on_slug", unique: true
t.index ["user_id"], name: "index_contests_on_user_id" t.index ["user_id"], name: "index_contests_on_user_id"
end end

View File

@@ -4,6 +4,7 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# code :string
# duration :string # duration :string
# duration_seconds :integer # duration_seconds :integer
# lang :string default("en") # lang :string default("en")