Compare commits
48 Commits
97ea17b7c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 279e7eaf3f | |||
| 683d99ab12 | |||
| 76553d4cbc | |||
| 31fe8789ce | |||
| 360157e0c8 | |||
| 5345e419df | |||
| 2c87a5b63c | |||
| 8cea403dc9 | |||
| cce090587a | |||
| ee250b96ad | |||
| 1fc05bea63 | |||
| 7bd1dce1ea | |||
| e2c50515b1 | |||
| 51e55f0828 | |||
| a2a8a9fcef | |||
| d08370f5f8 | |||
| cd41d83429 | |||
| 3a6ee2ea98 | |||
| d01775471e | |||
| 66d968fca8 | |||
| 7a80c434af | |||
| 768af7c3e9 | |||
| c0f2358a36 | |||
| 7a64fa181a | |||
| 024b254808 | |||
| 6ec4a89907 | |||
| d59090cded | |||
| ae3c7c73e1 | |||
| db6f732e63 | |||
| fdd47c231a | |||
| b2429b71f4 | |||
| 710953919c | |||
| 94e725d20a | |||
| b43a801e3c | |||
| c4f0f603f6 | |||
| 709719b801 | |||
| 3e071f9281 | |||
| b87800f6bd | |||
| bf127bb932 | |||
| 3dd153d587 | |||
| 63a88ea113 | |||
| ecb36e19ed | |||
| bc96b16bcb | |||
| 0f725e2eef | |||
| e67ee92838 | |||
| b88460ae71 | |||
| f91145637f | |||
| cdf87e48f2 |
@@ -16,7 +16,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 chromium && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Set production environment
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -46,6 +46,7 @@ gem "bootstrap", "~> 5.3.3"
|
||||
gem "friendly_id", "~> 5.5.0"
|
||||
gem "csv"
|
||||
gem "damerau-levenshtein"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
@@ -73,6 +74,7 @@ group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
gem "so_many_devices"
|
||||
end
|
||||
|
||||
gem "pundit", "~> 2.5"
|
||||
|
||||
49
Gemfile.lock
49
Gemfile.lock
@@ -84,7 +84,7 @@ GEM
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
bootsnap (1.19.0)
|
||||
msgpack (~> 1.2)
|
||||
bootstrap (5.3.5)
|
||||
popper_js (>= 2.11.8, < 3)
|
||||
@@ -100,6 +100,7 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
crass (1.0.6)
|
||||
@@ -116,7 +117,7 @@ GEM
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.3)
|
||||
erb (6.0.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -134,19 +135,19 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.33.0)
|
||||
google-protobuf (4.33.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-aarch64-linux-gnu)
|
||||
google-protobuf (4.33.1-aarch64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-aarch64-linux-musl)
|
||||
google-protobuf (4.33.1-aarch64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-gnu)
|
||||
google-protobuf (4.33.1-x86_64-linux-gnu)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
google-protobuf (4.33.0-x86_64-linux-musl)
|
||||
google-protobuf (4.33.1-x86_64-linux-musl)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
i18n (1.14.7)
|
||||
@@ -163,7 +164,7 @@ GEM
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.2)
|
||||
json (2.16.0)
|
||||
kamal (2.8.2)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -190,7 +191,7 @@ GEM
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.0)
|
||||
minitest (5.26.2)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
@@ -288,9 +289,13 @@ GEM
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
@@ -319,14 +324,14 @@ GEM
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.47.1)
|
||||
rubocop-ast (1.48.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.33.4)
|
||||
rubocop-rails (2.34.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -338,17 +343,17 @@ GEM
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (3.2.2)
|
||||
sass-embedded (1.93.3-aarch64-linux-gnu)
|
||||
sass-embedded (1.94.2-aarch64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-aarch64-linux-musl)
|
||||
sass-embedded (1.94.2-aarch64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-arm-linux-gnueabihf)
|
||||
sass-embedded (1.94.2-arm-linux-gnueabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-arm-linux-musleabihf)
|
||||
sass-embedded (1.94.2-arm-linux-musleabihf)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-x86_64-linux-gnu)
|
||||
sass-embedded (1.94.2-x86_64-linux-gnu)
|
||||
google-protobuf (~> 4.31)
|
||||
sass-embedded (1.93.3-x86_64-linux-musl)
|
||||
sass-embedded (1.94.2-x86_64-linux-musl)
|
||||
google-protobuf (~> 4.31)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.38.0)
|
||||
@@ -360,12 +365,14 @@ GEM
|
||||
slim (5.2.1)
|
||||
temple (~> 0.10.0)
|
||||
tilt (>= 2.1.0)
|
||||
so_many_devices (1.0.0)
|
||||
capybara (>= 3.0)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.9)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
@@ -391,7 +398,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
stringio (3.1.8)
|
||||
temple (0.10.4)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
@@ -455,10 +462,12 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
pundit (~> 2.5)
|
||||
rails (~> 8.0.2)
|
||||
rqrcode (~> 3.0)
|
||||
rspec-rails
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
slim
|
||||
so_many_devices
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
|
||||
@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :set_title, :set_current_user, :set_lang
|
||||
before_action :set_current_user, :set_lang
|
||||
after_action :verify_authorized
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
@@ -12,13 +12,6 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def set_title
|
||||
t_action_name = action_name
|
||||
t_action_name = "new" if action_name == "create"
|
||||
t_action_name = "edit" if action_name == "update"
|
||||
@title = I18n.t("#{controller_name}.#{t_action_name}.title")
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
@current_user = current_user
|
||||
end
|
||||
|
||||
@@ -8,21 +8,11 @@ class CompletionsController < ApplicationController
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
|
||||
@completion = Completion.new
|
||||
@completion.completed = true
|
||||
if params[:contestant_id]
|
||||
@@ -34,11 +24,9 @@ class CompletionsController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@completion = Completion.new(completion_params)
|
||||
@completion.contest_id = @contest.id
|
||||
@completion.contest = @contest
|
||||
if @completion.save
|
||||
if @completion.display_time_from_start.present?
|
||||
extend_completions!(@completion.contestant)
|
||||
end
|
||||
extend_completions!(@completion.contestant)
|
||||
if @contestant && !params[:completion].key?(:message_id)
|
||||
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
|
||||
else
|
||||
@@ -47,11 +35,6 @@ class CompletionsController < ApplicationController
|
||||
else
|
||||
if params[:completion].key?(:message_id)
|
||||
@message = Message.find(params[:completion][:message_id])
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
elsif @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -69,10 +52,6 @@ class CompletionsController < ApplicationController
|
||||
redirect_to @contest, notice: t("completions.edit.notice")
|
||||
end
|
||||
else
|
||||
if @contestant
|
||||
@action_name = t("helpers.buttons.back_to_contestant")
|
||||
@action_path = edit_contest_contestant_path(@contest, @contestant)
|
||||
end
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
session.delete(:return_to_after_authenticating) || "/contests"
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
|
||||
@@ -9,24 +9,38 @@ module CompletionsConcern
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
s.to_s
|
||||
m.to_s + ":" + pad(s)
|
||||
end
|
||||
|
||||
def extend_completions!(contestant)
|
||||
current_time_from_start = 0
|
||||
contestant.completions.order(:time_seconds).each do |completion|
|
||||
completion.update(display_time_from_start: display_time(completion.time_seconds),
|
||||
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
|
||||
current_time_from_start = completion.time_seconds
|
||||
completions = contestant.completions
|
||||
puzzles = contestant.contest.puzzles
|
||||
if puzzles.length > 1
|
||||
current_time_from_start = 0
|
||||
completions.order(:time_seconds).each do |completion|
|
||||
completion.update(display_time_from_start: display_time(completion.time_seconds),
|
||||
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
|
||||
current_time_from_start = completion.time_seconds
|
||||
end
|
||||
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
|
||||
elsif puzzles.length == 1 && completions.length >= 1
|
||||
if completions[0].remaining_pieces != nil
|
||||
contestant.update(
|
||||
display_time: "#{display_time(completions[0].time_seconds)} - #{puzzles[0].pieces - completions[0].remaining_pieces}p",
|
||||
time_seconds: completions[0].projected_time
|
||||
)
|
||||
else
|
||||
contestant.update(display_time: display_time(completions[0].time_seconds), time_seconds: completions[0].time_seconds)
|
||||
end
|
||||
end
|
||||
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
|
||||
end
|
||||
end
|
||||
|
||||
16
app/controllers/concerns/contestants_concern.rb
Normal file
16
app/controllers/concerns/contestants_concern.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module ContestantsConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ranked_contestants(contest)
|
||||
if contest.ranking_mode == "actual"
|
||||
contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
elsif contest.ranking_mode == "theorical"
|
||||
contest.contestants.sort_by { |contestant| contestant.completions.map { |completion| completion.projected_time }.sum }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,34 +1,26 @@
|
||||
class ContestantsController < ApplicationController
|
||||
before_action :set_contest
|
||||
include CompletionsConcern
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest, only: %i[ index edit new create update destroy import upload_csv convert_csv finalize_import export generate_qrcodes generate_qrcodes_pdf ]
|
||||
before_action :set_contestant, only: %i[ destroy edit update]
|
||||
before_action :set_completions, only: %i[edit update ]
|
||||
skip_before_action :require_authentication, only: %i[ get_public_completion post_public_completion public_completion_updated ]
|
||||
|
||||
def index
|
||||
authorize @contest
|
||||
|
||||
@title = @contest.name
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
filter_contestants_per_category
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = @contestant.name
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@contestant = Contestant.new
|
||||
end
|
||||
|
||||
@@ -41,8 +33,6 @@ class ContestantsController < ApplicationController
|
||||
update_contestant_categories
|
||||
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -52,10 +42,8 @@ class ContestantsController < ApplicationController
|
||||
|
||||
if @contestant.update(contestant_params)
|
||||
update_contestant_categories
|
||||
redirect_to @contest, notice: t("contestants.edit.notice")
|
||||
redirect_to contest_contestants_path(@contest), notice: t("contestants.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -83,8 +71,6 @@ class ContestantsController < ApplicationController
|
||||
if @csv_import.save
|
||||
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :import, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -92,8 +78,6 @@ class ContestantsController < ApplicationController
|
||||
def convert_csv
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@csv_import = CsvImport.find(params[:id])
|
||||
@content = JSON.parse(@csv_import.content)
|
||||
@form = Forms::CsvConversionForm.new
|
||||
@@ -112,15 +96,12 @@ class ContestantsController < ApplicationController
|
||||
if @form.email_column == -1
|
||||
Contestant.create(name: row[@form.name_column], contest: @contest)
|
||||
else
|
||||
logger.info("Email")
|
||||
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
|
||||
end
|
||||
end
|
||||
end
|
||||
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :convert_csv, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -128,21 +109,94 @@ class ContestantsController < ApplicationController
|
||||
def export
|
||||
authorize @contest
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
@contestants = ranked_contestants(@contest)
|
||||
|
||||
respond_to do |format|
|
||||
format.csv do
|
||||
response.headers["Content-Type"] = "text/csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=export.csv"
|
||||
response.headers["Content-Disposition"] = "attachment; filename=#{@contest.friendly_id}_results.csv"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_qrcodes
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf
|
||||
authorize @contest
|
||||
|
||||
generate_contestants_qrcodes(@contest)
|
||||
|
||||
@contestants = @contest.contestants.sort_by { |contestant| contestant.name }
|
||||
@nonav = true
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render layout: "blank" }
|
||||
end
|
||||
end
|
||||
|
||||
def get_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
@contest = @contestant.contest
|
||||
I18n.locale = @contest.lang
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@completion = Completion.new
|
||||
@completion.completed = true
|
||||
@public = true
|
||||
|
||||
render "completions/_form", locals: { completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/public/p/#{params[:token]}" }
|
||||
end
|
||||
|
||||
def post_public_completion
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
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
|
||||
|
||||
def public_completion_updated
|
||||
skip_authorization
|
||||
|
||||
@contestant = Contestant.find(params[:contestant_id])
|
||||
if !@contestant || !@contestant.contest.code.present?
|
||||
not_found and return
|
||||
end
|
||||
I18n.locale = @contestant.contest.lang
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contest
|
||||
@@ -181,4 +235,15 @@ class ContestantsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def completion_params
|
||||
params.expect(completion: [ :display_time_from_start, :completed, :missing_pieces, :remaining_pieces, :puzzle_id, :code ])
|
||||
end
|
||||
|
||||
def generate_contestants_qrcodes(contest)
|
||||
contest.contestants.where(qrcode: nil).each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class ContestsController < ApplicationController
|
||||
include CompletionsConcern
|
||||
include ContestantsConcern
|
||||
|
||||
before_action :set_contest, only: %i[ destroy edit show update ]
|
||||
before_action :set_settings_contest, only: %i[ settings_general_edit settings_general_update settings_offline_edit settings_offline_update settings_categories_edit ]
|
||||
before_action :set_contest, only: %i[ destroy show ]
|
||||
before_action :set_settings_contest, only: %i[ stopwatch stopwatch_continue stopwatch_pause stopwatch_reset stopwatch_start settings_general_edit settings_general_update settings_public_edit settings_public_update settings_onsite_edit settings_onsite_update settings_online_edit settings_online_update settings_categories_edit ]
|
||||
before_action :offline_setup, only: %i[ offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
skip_before_action :require_authentication, only: %i[ scoreboard offline_new offline_create offline_edit offline_update offline_completed ]
|
||||
|
||||
@@ -19,35 +20,24 @@ class ContestsController < ApplicationController
|
||||
redirect_to contest_contestants_path(@contest)
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
end
|
||||
|
||||
def settings_general_edit
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = t("contests.edit.title")
|
||||
end
|
||||
|
||||
def settings_offline_edit
|
||||
def settings_public_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = t("contests.edit.title")
|
||||
def settings_onsite_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_online_edit
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def settings_categories_edit
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = t("contests.edit.title")
|
||||
end
|
||||
|
||||
def settings_general_update
|
||||
@@ -56,56 +46,105 @@ class ContestsController < ApplicationController
|
||||
if @contest.update(settings_general_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = t("contests.edit.title")
|
||||
render :settings_general_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_offline_update
|
||||
def settings_public_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_offline_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/offline", notice: t("contests.edit.notice")
|
||||
if @contest.update(settings_public_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/public", notice: t("contests.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@title = t("contests.edit.title")
|
||||
render :settings_offline_edit, status: :unprocessable_entity
|
||||
render :settings_public_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_onsite_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_onsite_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/onsite", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_onsite_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings_online_update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(settings_online_params)
|
||||
redirect_to "/contests/#{@contest.id}/settings/online", notice: t("contests.edit.notice")
|
||||
else
|
||||
render :settings_online_edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def stopwatch
|
||||
authorize @contest
|
||||
end
|
||||
|
||||
def stopwatch_continue
|
||||
authorize @contest
|
||||
|
||||
pause_duration = Time.now() - @contest.pause_time
|
||||
@contest.start_time = @contest.start_time + pause_duration
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_pause
|
||||
authorize @contest
|
||||
authorize @contest
|
||||
|
||||
@contest.pause_time = Time.now()
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_reset
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = nil
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def stopwatch_start
|
||||
authorize @contest
|
||||
|
||||
@contest.start_time = Time.now()
|
||||
@contest.pause_time = nil
|
||||
@contest.save
|
||||
redirect_to "/contests/#{@contest.id}/stopwatch"
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :contest
|
||||
|
||||
@contest = Contest.new(contest_params)
|
||||
@contest = Contest.new(new_contest_params)
|
||||
@contest.lang = @current_user.lang
|
||||
@contest.ranking_mode = "actual"
|
||||
@contest.user_id = current_user.id
|
||||
if @contest.save
|
||||
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
|
||||
else
|
||||
@title = I18n.t("contests.new.title")
|
||||
@nonav = true
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contest
|
||||
|
||||
if @contest.update(contest_params)
|
||||
redirect_to @contest, notice: t("contests.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contest
|
||||
|
||||
@@ -124,23 +163,20 @@ class ContestsController < ApplicationController
|
||||
I18n.locale = @contest.lang
|
||||
|
||||
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
|
||||
@contestants = @contest.contestants.sort_by { |contestant| [
|
||||
-contestant.completions.where(remaining_pieces: nil).size,
|
||||
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds,
|
||||
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
|
||||
contestant.time_seconds
|
||||
] }
|
||||
filter_contestants_per_category
|
||||
@contestants = ranked_contestants(@contest)
|
||||
if params.key?(:category)
|
||||
@category = params[:category]
|
||||
filter_contestants_per_category
|
||||
end
|
||||
if params.key?(:hide_offline) && params[:hide_offline] == "true"
|
||||
@contestants = @contestants.select { |contestant| !contestant.offline.present? }
|
||||
@hide_offline = true
|
||||
end
|
||||
@puzzles = @contest.puzzles.order(:id)
|
||||
@action_name = t("helpers.buttons.refresh")
|
||||
if params.key?(:category)
|
||||
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
|
||||
else
|
||||
@action_path = "/public/#{@contest.friendly_id}"
|
||||
if params.key?(:autorefresh)
|
||||
@autorefresh = true
|
||||
end
|
||||
@puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
|
||||
@action_path = "/public/#{@contest.friendly_id}"
|
||||
@space = " "
|
||||
render :scoreboard
|
||||
end
|
||||
@@ -156,7 +192,7 @@ class ContestsController < ApplicationController
|
||||
@offline.contest = @contest
|
||||
@offline.start_time = Time.now()
|
||||
if @offline.save
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}"
|
||||
redirect_to offline_form_edit_path(@contest, @offline)
|
||||
else
|
||||
render :offline_new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -198,6 +234,7 @@ class ContestsController < ApplicationController
|
||||
contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline)
|
||||
Completion.create(contest: @contest,
|
||||
contestant: contestant,
|
||||
offline: @offline,
|
||||
puzzle: @contest.puzzles[0],
|
||||
completed: @offline.completed,
|
||||
display_time_from_start: dp,
|
||||
@@ -205,7 +242,7 @@ class ContestsController < ApplicationController
|
||||
remaining_pieces: @offline.remaining_pieces)
|
||||
extend_completions!(contestant)
|
||||
end
|
||||
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}/completed"
|
||||
redirect_to offline_form_completed_path(@contest, @offline)
|
||||
else
|
||||
render :offline_edit, status: :unprocessable_entity
|
||||
end
|
||||
@@ -236,24 +273,32 @@ class ContestsController < ApplicationController
|
||||
@contest = Contest.find(params[:contest_id])
|
||||
end
|
||||
|
||||
def contest_params
|
||||
params.expect(contest: [ :lang, :name, :offline_form, :public, :team, :allow_registration ])
|
||||
def new_contest_params
|
||||
params.expect(contest: [ :name, :duration ])
|
||||
end
|
||||
|
||||
def settings_general_params
|
||||
params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ])
|
||||
params.expect(contest: [ :lang, :name, :duration, :team, :allow_registration ])
|
||||
end
|
||||
|
||||
def settings_offline_params
|
||||
def settings_public_params
|
||||
params.expect(contest: [ :public, :ranking_mode, :show_stopwatch ])
|
||||
end
|
||||
|
||||
def settings_onsite_params
|
||||
params.expect(contest: [ :code ])
|
||||
end
|
||||
|
||||
def settings_online_params
|
||||
params.expect(contest: [ :offline_form ])
|
||||
end
|
||||
|
||||
def filter_contestants_per_category
|
||||
if params.key?(:category) && params[:category] != "-1"
|
||||
if params[:category] == "-2"
|
||||
if @category != "-1"
|
||||
if @category == "-2"
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
|
||||
else
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
|
||||
@contestants = @contestants.select { |contestant| contestant.categories.where(id: @category).any? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,9 +79,6 @@ class MessagesController < ApplicationController
|
||||
def convert
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
|
||||
@completion = Completion.new()
|
||||
@completion.display_time_from_start = @message.display_time
|
||||
@completion.completed = true
|
||||
@@ -94,7 +91,7 @@ class MessagesController < ApplicationController
|
||||
|
||||
@message = Message.find(params[:id])
|
||||
@message.destroy
|
||||
redirect_to contest_path(@contest), notice: t("messages.destroy.notice")
|
||||
redirect_to contest_messages_path(@contest), notice: t("messages.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -11,16 +11,11 @@ class PuzzlesController < ApplicationController
|
||||
|
||||
def edit
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
end
|
||||
|
||||
def new
|
||||
authorize @contest
|
||||
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
@puzzle = Puzzle.new
|
||||
end
|
||||
|
||||
@@ -30,10 +25,8 @@ class PuzzlesController < ApplicationController
|
||||
@puzzle = Puzzle.new(puzzle_params)
|
||||
@puzzle.contest_id = @contest.id
|
||||
if @puzzle.save
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.new.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -42,10 +35,8 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
if @puzzle.update(puzzle_params)
|
||||
redirect_to @contest, notice: t("puzzles.edit.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.edit.notice")
|
||||
else
|
||||
@action_name = t("helpers.buttons.back")
|
||||
@action_path = contest_path(@contest)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -54,7 +45,7 @@ class PuzzlesController < ApplicationController
|
||||
authorize @contest
|
||||
|
||||
@puzzle.destroy
|
||||
redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
redirect_to contest_puzzles_path(@contest), notice: t("puzzles.destroy.notice")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -68,6 +59,6 @@ class PuzzlesController < ApplicationController
|
||||
end
|
||||
|
||||
def puzzle_params
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces ])
|
||||
params.expect(puzzle: [ :brand, :name, :image, :pieces, :hidden ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class SessionsController < ApplicationController
|
||||
before_action :skip_authorization
|
||||
|
||||
def new
|
||||
@title = "Puzzle scoreboard"
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -11,6 +12,7 @@ class SessionsController < ApplicationController
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: t("sessions.new.notice")
|
||||
else
|
||||
@title = "Puzzle scoreboard"
|
||||
redirect_to new_session_path, alert: "Try another email address or password."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
class UsersController < ApplicationController
|
||||
include CompletionsConcern
|
||||
|
||||
before_action :set_user, only: %i[ destroy edit show update ]
|
||||
|
||||
def index
|
||||
authorize :user
|
||||
|
||||
@title = t("users.index.title")
|
||||
@users = User.all
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @user
|
||||
|
||||
@title = t("users.edit.title")
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @user
|
||||
|
||||
if @user.update(user_params)
|
||||
@user.password_change_attempt = false
|
||||
if @user.update(user_general_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def change_password
|
||||
@user = User.find(params[:user_id])
|
||||
authorize @user
|
||||
|
||||
@user.password_change_attempt = true
|
||||
if @user.update(user_password_params)
|
||||
redirect_to contests_path, notice: t("users.edit.notice")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
@@ -48,6 +66,35 @@ class UsersController < ApplicationController
|
||||
authorize @user
|
||||
end
|
||||
|
||||
def update_contestants
|
||||
authorize :user
|
||||
|
||||
total = 0
|
||||
updated = 0
|
||||
Contestant.all.each do |contestant|
|
||||
if contestant.completions.length > 0
|
||||
total += 1
|
||||
contestant.completions.each do |completion|
|
||||
completion.save
|
||||
end
|
||||
if extend_completions!(contestant)
|
||||
updated += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to users_path, notice: "Updated contestants: #{updated}/#{total}"
|
||||
end
|
||||
|
||||
def regenerate_qrcodes
|
||||
authorize :user
|
||||
|
||||
Contestant.all.each do |contestant|
|
||||
contestant.generate_qrcode
|
||||
contestant.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@@ -57,4 +104,12 @@ class UsersController < ApplicationController
|
||||
def user_params
|
||||
params.expect(user: [ :username, :email_address, :lang, :password ])
|
||||
end
|
||||
|
||||
def user_general_params
|
||||
params.expect(user: [ :username, :email_address, :lang ])
|
||||
end
|
||||
|
||||
def user_password_params
|
||||
params.expect(user: [ :password ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,14 +7,15 @@ module ContestsHelper
|
||||
end
|
||||
|
||||
def display_time(time)
|
||||
if time == nil
|
||||
return ""
|
||||
end
|
||||
h = time / 3600
|
||||
m = (time % 3600) / 60
|
||||
s = (time % 3600) % 60
|
||||
if h > 0
|
||||
return h.to_s + ":" + pad(m) + ":" + pad(s)
|
||||
elsif m > 0
|
||||
return m.to_s + ":" + pad(s)
|
||||
end
|
||||
"0:" + pad(s)
|
||||
m.to_s + ":" + pad(s)
|
||||
end
|
||||
end
|
||||
|
||||
3
app/lib/ranking.rb
Normal file
3
app/lib/ranking.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
module Ranking
|
||||
AVAILABLE_RANKING_MODES = [ { id: "actual", name: I18n.t("lib.ranking.actual") }, { id: "theorical", name: I18n.t("lib.ranking.theorical") } ]
|
||||
end
|
||||
@@ -3,10 +3,12 @@
|
||||
# Table name: completions
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# code :string
|
||||
# completed :boolean
|
||||
# display_relative_time :string
|
||||
# display_time_from_start :string
|
||||
# missing_pieces :integer
|
||||
# projected_time :integer
|
||||
# remaining_pieces :integer
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
@@ -31,43 +33,45 @@
|
||||
# puzzle_id (puzzle_id => puzzles.id)
|
||||
#
|
||||
class Completion < ApplicationRecord
|
||||
include ContestsHelper
|
||||
|
||||
belongs_to :contest
|
||||
belongs_to :contestant
|
||||
belongs_to :puzzle
|
||||
belongs_to :message, optional: true
|
||||
|
||||
before_save :add_time_seconds, if: -> { display_time_from_start.present? }
|
||||
before_save :nullify_display_time
|
||||
before_save :clean_pieces
|
||||
has_one :offline, dependent: :destroy
|
||||
|
||||
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed }
|
||||
before_save :clean_pieces
|
||||
before_save :compute_projected_time
|
||||
|
||||
validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }, if: -> { completed || offline.present? }
|
||||
validates :remaining_pieces, presence: true, if: -> { !completed }
|
||||
validates :contestant_id, uniqueness: { scope: :puzzle }, 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? }
|
||||
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")
|
||||
end
|
||||
end
|
||||
|
||||
def nullify_display_time
|
||||
if self.remaining_pieces
|
||||
self.display_time_from_start = nil
|
||||
self.display_relative_time = nil
|
||||
errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large"))
|
||||
end
|
||||
end
|
||||
|
||||
def add_time_seconds
|
||||
arr = display_time_from_start.split(":")
|
||||
if arr.size == 3
|
||||
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
|
||||
elsif arr.size == 2
|
||||
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
|
||||
elsif arr.size == 1
|
||||
self.time_seconds = arr[0].to_i
|
||||
if display_time_from_start.present?
|
||||
arr = display_time_from_start.split(":")
|
||||
if arr.size == 3
|
||||
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
|
||||
elsif arr.size == 2
|
||||
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
|
||||
elsif arr.size == 1
|
||||
self.time_seconds = arr[0].to_i
|
||||
end
|
||||
else
|
||||
self.time_seconds = self.contest.duration_seconds
|
||||
self.display_time_from_start = display_time(self.time_seconds)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,4 +82,22 @@ class Completion < ApplicationRecord
|
||||
self.missing_pieces = nil
|
||||
end
|
||||
end
|
||||
|
||||
def compute_projected_time
|
||||
add_time_seconds
|
||||
if self.completed
|
||||
self.projected_time = self.time_seconds
|
||||
else
|
||||
assembled_time = self.time_seconds
|
||||
assembled_pieces = self.puzzle.pieces - self.remaining_pieces
|
||||
pieces_per_second = assembled_pieces.to_f / assembled_time.to_f
|
||||
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
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# code :string
|
||||
# duration :string
|
||||
# duration_seconds :integer
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# pause_time :datetime
|
||||
# public :boolean default(FALSE)
|
||||
# ranking_mode :string
|
||||
# show_stopwatch :boolean
|
||||
# slug :string
|
||||
# start_time :datetime
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -36,8 +43,19 @@ class Contest < ApplicationRecord
|
||||
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
before_save :add_duration_seconds, if: -> { duration.present? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
|
||||
validates :ranking_mode, inclusion: { in: Ranking::AVAILABLE_RANKING_MODES.map { |lang| lang[:id] } }
|
||||
validates :duration, presence: true, format: { with: /\A(\d\d:\d\d|\d:\d\d)\z/ }
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
def add_duration_seconds
|
||||
arr = self.duration.split(":")
|
||||
if arr.size == 2
|
||||
self.duration_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
#
|
||||
# Table name: contestants
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# projected_time :string
|
||||
# qrcode :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -22,7 +24,7 @@
|
||||
class Contestant < ApplicationRecord
|
||||
belongs_to :contest
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one :offline
|
||||
has_one :offline, dependent: :destroy
|
||||
has_and_belongs_to_many :categories
|
||||
|
||||
before_validation :initialize_time_seconds_if_empty
|
||||
@@ -38,6 +40,19 @@ class Contestant < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def generate_qrcode
|
||||
host = Rails.application.config.action_controller.default_url_options[:host]
|
||||
qrcode = RQRCode::QRCode.new("https://#{host}/public/p/#{self.id}")
|
||||
self.qrcode = qrcode.as_svg(
|
||||
color: "000",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 3,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
viewbox: true
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_time_seconds_if_empty
|
||||
|
||||
@@ -12,24 +12,28 @@
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# completion_id :integer
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_completion_id (completion_id)
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# completion_id (completion_id => completions.id)
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
class Offline < ApplicationRecord
|
||||
belongs_to :contest
|
||||
belongs_to :contestant, optional: true
|
||||
belongs_to :completion, optional: true
|
||||
|
||||
has_many_attached :images
|
||||
has_many_attached :images, dependent: :destroy
|
||||
|
||||
generates_token_for :token
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# brand :string
|
||||
# hidden :boolean
|
||||
# name :string
|
||||
# pieces :integer not null
|
||||
# created_at :datetime not null
|
||||
@@ -22,7 +23,7 @@ class Puzzle < ApplicationRecord
|
||||
belongs_to :contest
|
||||
|
||||
has_many :completions, dependent: :destroy
|
||||
has_one_attached :image
|
||||
has_one_attached :image, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :pieces, presence: true
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -35,6 +35,10 @@ class ContestPolicy < ApplicationPolicy
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def settings_general_edit?
|
||||
edit?
|
||||
end
|
||||
@@ -43,11 +47,27 @@ class ContestPolicy < ApplicationPolicy
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_offline_edit?
|
||||
def settings_public_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_offline_update?
|
||||
def settings_public_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_onsite_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_edit?
|
||||
edit?
|
||||
end
|
||||
|
||||
def settings_online_update?
|
||||
edit?
|
||||
end
|
||||
|
||||
@@ -55,6 +75,26 @@ class ContestPolicy < ApplicationPolicy
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_continue?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_pause?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_reset?
|
||||
edit?
|
||||
end
|
||||
|
||||
def stopwatch_start?
|
||||
edit?
|
||||
end
|
||||
|
||||
def finalize_import?
|
||||
owner_or_admin
|
||||
end
|
||||
@@ -75,6 +115,10 @@ class ContestPolicy < ApplicationPolicy
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def generate_qrcodes_pdf?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
def upload_csv?
|
||||
owner_or_admin
|
||||
end
|
||||
|
||||
@@ -20,10 +20,22 @@ class UserPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin? || user.id == record.id
|
||||
edit?
|
||||
end
|
||||
|
||||
def change_password?
|
||||
edit?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def update_contestants?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def regenerate_qrcodes?
|
||||
user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,35 +1,3 @@
|
||||
javascript:
|
||||
async function copyExtensionUrlToClipboard() {
|
||||
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
|
||||
alert("#{t("contests.show.url_copied")}");
|
||||
}
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
- if @contest.public
|
||||
a.btn.btn-success href="/public/#{@contest.slug}"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.btn.btn-success.disabled
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
a.ms-3.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.ms-3.btn.btn-success.disabled
|
||||
= t("contests.show.offline_form_disabled")
|
||||
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
|
||||
css:
|
||||
button > svg {
|
||||
margin-right: 2px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
|
||||
</svg>
|
||||
=< t("contests.show.copy_extension_url")
|
||||
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
@@ -39,6 +7,12 @@ javascript:
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest)
|
||||
= t("puzzles.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/stopwatch") href="/contests/#{@contest.id}/stopwatch"
|
||||
= t("contests.nav.stopwatch").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
|
||||
= t("messages.plural").capitalize
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.settings")
|
||||
18
app/views/application/_params_nav.html.slim
Normal file
18
app/views/application/_params_nav.html.slim
Normal file
@@ -0,0 +1,18 @@
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.nav.general")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/public") href="/contests/#{@contest.id}/settings/public"
|
||||
= t("contests.nav.public")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/onsite") href="/contests/#{@contest.id}/settings/onsite"
|
||||
= t("contests.nav.onsite")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/online") href="/contests/#{@contest.id}/settings/online"
|
||||
= t("contests.nav.online")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
|
||||
= t("contests.nav.categories")
|
||||
@@ -1,86 +1,103 @@
|
||||
= form_with model: completion, url: url, method: method do |form|
|
||||
- if @message
|
||||
= form.hidden_field :message_id, value: @message.id
|
||||
- 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
|
||||
- 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
|
||||
h4 = t("messages.singular").capitalize
|
||||
.alert.alert-secondary
|
||||
b
|
||||
= @message.author
|
||||
br
|
||||
= @message.text
|
||||
.row
|
||||
.col
|
||||
h4 = t("completions.singular").capitalize
|
||||
.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
|
||||
.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.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.style.display = 'block';
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.row.mb-3 id="time"
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
|
||||
= 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');
|
||||
timeEl = document.getElementById('time');
|
||||
missingPiecesEl = document.getElementById('missing_pieces');
|
||||
remainingPiecesEl = document.getElementById('remaining_pieces');
|
||||
if (completedEl.checked) {
|
||||
timeEl.style.display = 'block';
|
||||
missingPiecesEl.style.display = 'block';
|
||||
remainingPiecesEl.style.display = 'none';
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
missingPiecesEl.style.display = 'none';
|
||||
remainingPiecesEl.style.display = 'block';
|
||||
}
|
||||
.row
|
||||
.col
|
||||
= form.submit submit_text, class: "btn btn-primary"
|
||||
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
|
||||
= form.label :display_time_from_start, class: "required"
|
||||
.form-text
|
||||
= t("activerecord.attributes.completion.display_time_from_start_description")
|
||||
.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"
|
||||
@@ -1,7 +1,3 @@
|
||||
.row
|
||||
.col
|
||||
h3 Informations
|
||||
|
||||
= form_with model: contestant, url: url, method: method do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
@@ -15,7 +11,7 @@
|
||||
= form.label :email
|
||||
.form-text
|
||||
= t("activerecord.attributes.contestant.email_description")
|
||||
- if @contest.categories
|
||||
- if @contest.categories && method == :patch
|
||||
.row.mt-4
|
||||
.col
|
||||
- @contest.categories.each do |category|
|
||||
@@ -33,53 +29,72 @@
|
||||
.row.mt-5
|
||||
.col
|
||||
h3 Completions
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.edit.completions_note")
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
- if @contest.puzzles.size > 1
|
||||
- if @contest.puzzles.length == 0
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.edit.no_puzzles_note")
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.edit.completions_note")
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time_from_start")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_relative_time")
|
||||
- else
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.missing_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.remaining_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.puzzle")
|
||||
tbody
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
= completion.display_time_from_start
|
||||
= t("activerecord.attributes.completion.completed")
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time_from_start")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_relative_time")
|
||||
- else
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.display_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.projected_time")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.missing_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.remaining_pieces")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.completion.puzzle")
|
||||
tbody
|
||||
- @completions.each do |completion|
|
||||
tr scope="row"
|
||||
td
|
||||
= completion.display_relative_time
|
||||
td
|
||||
= completion.missing_pieces
|
||||
td
|
||||
= completion.remaining_pieces
|
||||
td
|
||||
- if !completion.puzzle.brand.blank?
|
||||
| #{completion.puzzle.name} - #{completion.puzzle.brand}
|
||||
- if completion.completed
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
|
||||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
|
||||
</svg>
|
||||
td
|
||||
= display_time(completion.time_seconds)
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= completion.display_relative_time
|
||||
- else
|
||||
| #{completion.puzzle.name}
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant_id: contestant.id)
|
||||
= t("helpers.buttons.edit")
|
||||
= link_to t("helpers.buttons.delete"), contest_completion_path(contest, completion, contestant_id: contestant.id),
|
||||
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
|
||||
.row
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
|
||||
= t("helpers.buttons.add")
|
||||
td
|
||||
= display_time(completion.projected_time)
|
||||
td
|
||||
= completion.missing_pieces
|
||||
td
|
||||
= completion.remaining_pieces
|
||||
td
|
||||
- if !completion.puzzle.brand.blank?
|
||||
| #{completion.puzzle.name} - #{completion.puzzle.brand}
|
||||
- else
|
||||
| #{completion.puzzle.name}
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant_id: contestant.id)
|
||||
= t("helpers.buttons.edit")
|
||||
= link_to t("helpers.buttons.delete"), contest_completion_path(contest, completion, contestant_id: contestant.id),
|
||||
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
|
||||
.row
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
|
||||
= t("helpers.buttons.add")
|
||||
|
||||
- if contestant.offline.present?
|
||||
.row.mt-5.mb-2
|
||||
|
||||
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
27
app/views/contestants/generate_qrcodes.html.slim
Normal file
@@ -0,0 +1,27 @@
|
||||
- if @contest.code.present?
|
||||
.mb-4 style="height: calc(100vh - 280px)"
|
||||
.row
|
||||
.col
|
||||
.alert.alert-info
|
||||
= t("contestants.generate_qrcodes.note")
|
||||
|
||||
a.mt-3.mb-3.btn.btn-primary href="#{contest_generate_qrcodes_pdf_path(@contest)}" target="_blank" style="margin-top: -3px"
|
||||
= t("helpers.buttons.open_raw")
|
||||
|
||||
.col.d-flex.flex-column style="height: calc(100% - 200px)"
|
||||
.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"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
- else
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contestants.generate_qrcodes.no_code_note")
|
||||
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
13
app/views/contestants/generate_qrcodes_pdf.html.slim
Normal file
@@ -0,0 +1,13 @@
|
||||
h1.text-center.mt-4.mb-4 = @contest.name
|
||||
|
||||
.d-flex.flex-column.align-items-center
|
||||
- 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-4.me-4 style="align-items: center"
|
||||
- if @contestants[row * 4 + col].qrcode.present?
|
||||
.mt-1 style="width: 128px; height: 120px;"
|
||||
= @contestants[row * 4 + col].qrcode.html_safe
|
||||
.name.text-center.mt-3 style="font-size: 0.7rem; max-width: 128px; height: 30px;"
|
||||
= @contestants[row * 4 + col].name
|
||||
@@ -1,5 +1,3 @@
|
||||
= render "contest_nav"
|
||||
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
.row.mb-4
|
||||
@@ -8,6 +6,8 @@
|
||||
| + #{t("helpers.buttons.add")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.import")}
|
||||
a.ms-2.btn.btn.btn-primary href=contest_generate_qrcodes_path(@contest) style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.generate_qrcodes")}
|
||||
a.ms-2.btn.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
|
||||
| #{t("helpers.buttons.export")}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
if (option.value == selectedCategory) option.selected = true;
|
||||
});
|
||||
categorySelectEl.addEventListener('change', (e) => {
|
||||
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
|
||||
window.location.replace(`#{contest_contestants_path(@contest)}?category=${e.target.value}`);
|
||||
})
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
@@ -61,4 +61,4 @@
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
|
||||
= t("helpers.buttons.open")
|
||||
= t("helpers.buttons.details")
|
||||
@@ -1 +1,2 @@
|
||||
h5.mb-3 = t("contestants.new.title")
|
||||
= render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"
|
||||
@@ -0,0 +1,2 @@
|
||||
h4
|
||||
= "Puzzle validé pour #{@contestant.name} !"
|
||||
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
50
app/views/contests/_scoreboard_desktop_marathon.html.slim
Normal file
@@ -0,0 +1,50 @@
|
||||
.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
|
||||
= render "selectors"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
.table-responsive-md
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400;"
|
||||
= render "stopwatch_js"
|
||||
th
|
||||
th
|
||||
th
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 64px;") if puzzle.image.attached?
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
- @puzzles.each do |puzzle|
|
||||
th scope="col"
|
||||
= puzzle.name
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
- @puzzles.each do |puzzle|
|
||||
td
|
||||
- contestant.completions.each do |completion|
|
||||
- if completion.puzzle == puzzle
|
||||
- if completion.completed
|
||||
= completion.display_relative_time
|
||||
- elsif completion.remaining_pieces.present?
|
||||
= "#{puzzle.pieces - completion.remaining_pieces}p"
|
||||
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
43
app/views/contests/_scoreboard_desktop_single.html.slim
Normal file
@@ -0,0 +1,43 @@
|
||||
.row
|
||||
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 310px)"
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
|
||||
.mt-3.fs-4 style="margin-left: 15px"
|
||||
= puzzle.name
|
||||
.fs-6 style="margin-left: 15px"
|
||||
b
|
||||
= "#{puzzle.brand} - #{puzzle.pieces}p"
|
||||
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
46
app/views/contests/_scoreboard_mobile_single.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
css:
|
||||
.container { margin-top: 2rem !important; }
|
||||
|
||||
.row
|
||||
- if @puzzles.size > 0
|
||||
.d-flex.flex-column.justify-content-center.mb-2
|
||||
= image_tag(@puzzles[0].image, style: "max-height: 200px; object-fit: contain") if @puzzles[0].image.attached?
|
||||
.mt-2.fs-6 style="text-align: center"
|
||||
=> "#{@puzzles[0].name} -"
|
||||
= "#{@puzzles[0].brand} #{@puzzles[0].pieces}p"
|
||||
|
||||
.row
|
||||
.mt-3.d-flex.flex-column
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(completed: true).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 112px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.display_time
|
||||
.col-1
|
||||
@@ -1,15 +1,3 @@
|
||||
javascript:
|
||||
function updateParams() {
|
||||
categorySelectEl = document.getElementById('categories');
|
||||
offlineInputEl = document.getElementById('offline');
|
||||
if (categorySelectEl && !offlineInputEl) {
|
||||
window.location.replace(`/public/#{@contest.slug}?category=${categorySelectEl.value}`);
|
||||
} else if (!categorySelectEl) {
|
||||
window.location.replace(`/public/#{@contest.slug}?hide_offline=${offlineInputEl.checked}`);
|
||||
} else {
|
||||
window.location.replace(`/public/#{@contest.slug}?category=${categorySelectEl.value}&hide_offline=${offlineInputEl.checked}`);
|
||||
}
|
||||
}
|
||||
- if @contest.categories.size > 0
|
||||
.row
|
||||
.col
|
||||
@@ -17,29 +5,29 @@ javascript:
|
||||
option value=-1
|
||||
= t("contests.scoreboard.all_categories")
|
||||
- @contest.categories.each do |category|
|
||||
option value=category.id
|
||||
= category.name
|
||||
- if @category == category.id.to_s
|
||||
option value=category.id selected=true
|
||||
= category.name
|
||||
- else
|
||||
option value=category.id
|
||||
= category.name
|
||||
javascript:
|
||||
categorySelectEl = document.getElementById('categories');
|
||||
urlParams = new URLSearchParams(window.location.search);
|
||||
selectedCategory = urlParams.get('category');
|
||||
Array.from(categorySelectEl.children).forEach((option) => {
|
||||
if (option.value == selectedCategory) option.selected = true;
|
||||
});
|
||||
categorySelectEl.addEventListener('change', (e) => {
|
||||
updateParams();
|
||||
document.getElementById('categories').addEventListener('change', (e) => {
|
||||
addParam('category', e.target.value);
|
||||
})
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
.row
|
||||
.col
|
||||
input type="checkbox" id="offline" style="padding: 5px;"
|
||||
- if @hide_offline
|
||||
input type="checkbox" id="offline" style="padding: 5px;" checked=true
|
||||
- else
|
||||
input type="checkbox" id="offline" style="padding: 5px;"
|
||||
label for="offline"
|
||||
.ms-2
|
||||
= t("contests.scoreboard.hide_offline")
|
||||
javascript:
|
||||
offlineInputEl = document.getElementById('offline');
|
||||
urlParams = new URLSearchParams(window.location.search);
|
||||
offlineInputEl.checked = urlParams.get('hide_offline') == "true";
|
||||
offlineInputEl.addEventListener('change', (e) => {
|
||||
updateParams();
|
||||
document.getElementById('offline').addEventListener('change', (e) => {
|
||||
console.log('changed');
|
||||
if (e.target.checked) addParam('hide_offline', e.target.checked);
|
||||
else removeParam('hide_offline');
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
.row
|
||||
.col
|
||||
ul.nav.nav-tabs.mb-4
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/general") href="/contests/#{@contest.id}/settings/general"
|
||||
= t("contests.form.general")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/offline") href="/contests/#{@contest.id}/settings/offline"
|
||||
= t("contests.form.offline")
|
||||
li.nav-item
|
||||
a.nav-link class=active_page("/contests/#{@contest.id}/settings/categories") href="/contests/#{@contest.id}/settings/categories"
|
||||
= t("contests.form.categories")
|
||||
22
app/views/contests/_stopwatch_js.html.slim
Normal file
22
app/views/contests/_stopwatch_js.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
javascript:
|
||||
startTime = #{@contest.start_time.present? ? @contest.start_time.to_i : "null"};
|
||||
pauseTime = #{@contest.pause_time.present? ? @contest.pause_time.to_i : "null"};
|
||||
function updateTime() {
|
||||
const displayTimeEl = document.getElementById('display-time');
|
||||
if (displayTimeEl) {
|
||||
if (startTime) {
|
||||
let s = Math.floor((Date.now() - 1000 * startTime) / 1000);
|
||||
if (pauseTime) s = Math.floor(pauseTime - startTime);
|
||||
let ss = s % 60;
|
||||
let mm = Math.floor(s / 60) % 60;
|
||||
let hh = Math.floor(s / 3600);
|
||||
displayTimeEl.innerHTML = `${hh < 10 ? `0${hh}` : hh}:${mm < 10 ? `0${mm}` : mm}:${ss < 10 ? `0${ss}` : ss}`;
|
||||
setTimeout(updateTime, 1000);
|
||||
} else {
|
||||
displayTimeEl.innerHTML = '00:00:00';
|
||||
}
|
||||
} else {
|
||||
setTimeout(updateTime, 20);
|
||||
}
|
||||
}
|
||||
setTimeout(updateTime, 1);
|
||||
@@ -4,6 +4,12 @@
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.create"), class: "btn btn-primary"
|
||||
@@ -21,14 +21,14 @@
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.end_image_select")
|
||||
= form.file_field :end_image, accept: "image/*", class: "form-control"
|
||||
= form.file_field :end_image, accept: "image/*", class: "form-control", capture: "user"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
.col
|
||||
.form-text.mb-1
|
||||
= t("offlines.form.start_image_select")
|
||||
= form.file_field :images, accept: "image/*", class: "form-control"
|
||||
= form.file_field :images, accept: "image/*", class: "form-control", capture: "user"
|
||||
.form-text.error-message style="display: none;" id="image-error-message"
|
||||
= t("puzzles.form.file_too_big")
|
||||
javascript:
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
|
||||
@@ -1,97 +1,25 @@
|
||||
css:
|
||||
@media (max-width: 800px) {
|
||||
a.btn { display: none; }
|
||||
.col-5 { display: none; }
|
||||
.col-6 { width: 100% !important; display: block !important; }
|
||||
.small-screen-image { display: block !important; }
|
||||
.container { margin-top: 2rem !important; }
|
||||
.mobile-single { display: block !important; }
|
||||
.desktop-single { display: none; }
|
||||
#scoreboard-switches { display: none; }
|
||||
.stopwatch { font-size: 50px !important; }
|
||||
}
|
||||
|
||||
- if @contest.puzzles.size <= 1
|
||||
.row.small-screen-image style="display: none"
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
.d-flex.flex-column.justify-content-center.mb-5
|
||||
= image_tag(puzzle.image, style: "max-height: 200px; object-fit: contain") if puzzle.image.attached?
|
||||
.mt-2.fs-6 style="text-align: center"
|
||||
=> "#{puzzle.name} -"
|
||||
= "#{puzzle.brand} #{puzzle.pieces}p"
|
||||
|
||||
- if @contest.puzzles.size < 2
|
||||
= render "selectors"
|
||||
|
||||
.row
|
||||
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 250px)"
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("helpers.rank")
|
||||
th
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th style="width: 170px"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
- if contestant.offline.present?
|
||||
= @space
|
||||
| (hors-ligne)
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td style="position: relative"
|
||||
- if index > 0 && contestant.time_seconds > 0 && contestant.completions.where(remaining_pieces: nil).size > 0
|
||||
.relative-time style="position:absolute; margin: 1px 0 0 64px; font-size: 14px; color: grey"
|
||||
|> +
|
||||
= display_time(contestant.time_seconds - @contestants[index - 1].time_seconds)
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
.col-1
|
||||
.col-5
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3") if puzzle.image.attached?
|
||||
.mt-3.fs-4 style="margin-left: 15px"
|
||||
= puzzle.name
|
||||
.fs-6 style="margin-left: 15px"
|
||||
b
|
||||
= "#{puzzle.brand} - #{puzzle.pieces}p"
|
||||
turbo-frame id="scoreboard"
|
||||
- if @contest.show_stopwatch
|
||||
.stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;"
|
||||
= render "stopwatch_js"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
.mobile-single style="display: none;"
|
||||
= render "scoreboard_mobile_single"
|
||||
.desktop-single
|
||||
= render "scoreboard_desktop_single"
|
||||
|
||||
- else
|
||||
.d-flex.flex-column style="height: calc(100vh - 180px)"
|
||||
.d-flex.flex-row.justify-content-center.mb-5
|
||||
- @contest.puzzles.each do |puzzle|
|
||||
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3", style: "max-height: 220px") if puzzle.image.attached?
|
||||
|
||||
= render "selectors"
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
= t("helpers.rank")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.name")
|
||||
- if @contest.puzzles.size > 1
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.completions")
|
||||
th scope="col"
|
||||
= t("activerecord.attributes.contestant.display_time")
|
||||
tbody
|
||||
- @contestants.each_with_index do |contestant, index|
|
||||
tr scope="row"
|
||||
td
|
||||
= index + 1
|
||||
td
|
||||
= contestant.name
|
||||
- if @contest.puzzles.size > 1
|
||||
td
|
||||
= contestant.completions.where(remaining_pieces: nil).length
|
||||
td
|
||||
= contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? "#{contestant.completions.map{|completion| completion.puzzle.pieces}.sum - contestant.completions[-1].remaining_pieces}p" : contestant.display_time
|
||||
turbo-frame id="scoreboard"
|
||||
a.btn.btn-primary href="" id="refresh-button" style="display: none;"
|
||||
= render "scoreboard_desktop_marathon"
|
||||
@@ -1,4 +1,9 @@
|
||||
= render "settings_nav"
|
||||
= render "params_nav"
|
||||
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary role="alert"
|
||||
= t("contests.nav.categories_description")
|
||||
|
||||
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
|
||||
- if @contest.categories.size > 0
|
||||
@@ -26,6 +31,6 @@
|
||||
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
= t("activerecord.attributes.category.new")
|
||||
.row.mt-3
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"
|
||||
@@ -1,4 +1,4 @@
|
||||
= render "settings_nav"
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
|
||||
.row.mt-2.mb-3
|
||||
@@ -6,17 +6,24 @@
|
||||
.form-floating
|
||||
= form.text_field :name, autocomplete: "off", class: "form-control"
|
||||
= form.label :name, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.text_field :duration, autocomplete: "off", class: "form-control"
|
||||
= form.label :duration, class: "required"
|
||||
.form-text = t("activerecord.attributes.contest.duration_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
|
||||
= form.label :lang
|
||||
.row.mt-4.mb-3
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
.row.mb-3
|
||||
.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.mb-3 style="display: none"
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :team, class: "form-check-input"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
= render "settings_nav"
|
||||
|
||||
- if @contest.puzzles.length > 1
|
||||
.row
|
||||
.col
|
||||
.alert.alert-warning
|
||||
= t("contests.form.offline_single_puzzle_warning")
|
||||
|
||||
- if @contest.puzzles.length <= 1
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/offline" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
- if @contest.puzzles.length <= 1
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
- else
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
46
app/views/contests/settings_online_edit.html.slim
Normal file
46
app/views/contests/settings_online_edit.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
= render "params_nav"
|
||||
|
||||
javascript:
|
||||
async function copyExtensionUrlToClipboard() {
|
||||
await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
|
||||
alert("#{t("contests.show.url_copied")}");
|
||||
}
|
||||
|
||||
.row.mb-4.mt-2
|
||||
.col
|
||||
- if @contest.offline_form && @contest.puzzles.length < 2
|
||||
a.btn.btn-success href="/public/#{@contest.slug}/offline"
|
||||
= t("contests.show.open_offline_form")
|
||||
- else
|
||||
a.btn.btn-success.disabled
|
||||
= t("contests.show.offline_form_disabled")
|
||||
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
|
||||
css:
|
||||
button > svg {
|
||||
margin-right: 2px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
|
||||
</svg>
|
||||
=< t("contests.show.copy_extension_url")
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/online" do |form|
|
||||
.row.mt-2.mb-3
|
||||
.col
|
||||
- if @contest.puzzles.length <= 1
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form, class: "form-check-input"
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
- else
|
||||
.form-check.form-switch
|
||||
= form.check_box :offline_form_fake, class: "form-check-input", disabled: true
|
||||
= form.label :offline_form
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_warning")
|
||||
.form-text = t("activerecord.attributes.contest.offline_form_description")
|
||||
.row.mt-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
12
app/views/contests/settings_onsite_edit.html.slim
Normal file
@@ -0,0 +1,12 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
|
||||
.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
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
22
app/views/contests/settings_public_edit.html.slim
Normal file
22
app/views/contests/settings_public_edit.html.slim
Normal file
@@ -0,0 +1,22 @@
|
||||
= render "params_nav"
|
||||
|
||||
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/public" do |form|
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :public, class: "form-check-input"
|
||||
= form.label :public
|
||||
.row.mt-2
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :show_stopwatch, class: "form-check-input"
|
||||
= form.label :show_stopwatch
|
||||
.row.mt-3
|
||||
.col
|
||||
.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-4
|
||||
.col
|
||||
= form.submit t("helpers.buttons.update"), class: "btn btn-primary"
|
||||
16
app/views/contests/stopwatch.html.slim
Normal file
16
app/views/contests/stopwatch.html.slim
Normal file
@@ -0,0 +1,16 @@
|
||||
.row
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("contests.stopwatch.info")
|
||||
h1.mt-3 id="display-time" style="font-size: 80px;"
|
||||
= render "stopwatch_js"
|
||||
.row.mt-3
|
||||
.col.d-flex
|
||||
- if !@contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_start"), "/contests/#{@contest.id}/stopwatch_start", method: :post, class: "btn btn-primary"
|
||||
- if @contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_continue"), "/contests/#{@contest.id}/stopwatch_continue", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present? && !@contest.pause_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_pause"), "/contests/#{@contest.id}/stopwatch_pause", method: :post, class: "btn btn-primary"
|
||||
- if @contest.start_time.present?
|
||||
= button_to t("helpers.buttons.stopwatch_reset"), "/contests/#{@contest.id}/stopwatch_reset", method: :post, class: "ms-3 btn btn-warning"
|
||||
@@ -5,7 +5,7 @@ html
|
||||
body
|
||||
.container.mt-5
|
||||
- if @current_user
|
||||
.float-end style="margin-top: -8px;"
|
||||
.float-end style="margin-top: -5px;"
|
||||
nav.navbar.bg-body-primary
|
||||
- if @current_user.admin
|
||||
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
|
||||
@@ -44,9 +44,65 @@ html
|
||||
= msg
|
||||
|
||||
h1.mb-4
|
||||
= @title
|
||||
- if @action_path
|
||||
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
|
||||
= @action_name
|
||||
- if @contest && @contest.id.present?
|
||||
- if active_page("/public") == "active" && @action_path
|
||||
= @contest.name
|
||||
.float-end style="margin-top: -5px;" id="scoreboard-switches"
|
||||
.d-inline-flex.align-items-center
|
||||
.ms-4.form-check.form-switch style="font-size: 16px; font-weight: 300;"
|
||||
input.form-check-input type="checkbox" id="refresh-checkbox"
|
||||
label.ms-1 style="font-size: 16px; font-weight: 300;"
|
||||
= t("contests.scoreboard.auto_refresh")
|
||||
.js data-turbo="false"
|
||||
javascript:
|
||||
function refresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) {
|
||||
addParam('autorefresh', 1);
|
||||
setTimeout(refresh, 30000);
|
||||
}
|
||||
}
|
||||
function addParam(key, value) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
urlParams.append(key, value);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function removeParam(key) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete(key);
|
||||
const refreshBtn = document.getElementById('refresh-button')
|
||||
refreshBtn.href = `/public/#{@contest.friendly_id}?${urlParams.toString()}`;
|
||||
refreshBtn.click();
|
||||
}
|
||||
function autoRefresh() {
|
||||
if (document.getElementById('refresh-checkbox').checked) setTimeout(refresh, 30000);
|
||||
document.getElementById('refresh-checkbox').addEventListener('change', (e) => {
|
||||
if (e.target.checked) refresh();
|
||||
else removeParam('autorefresh');
|
||||
});
|
||||
}
|
||||
async function startAutoRefresh(count) {
|
||||
if (count == 0) return;
|
||||
if (document.getElementById('refresh-button') && document.getElementById('refresh-checkbox')) autoRefresh();
|
||||
else setTimeout(() => startAutoRefresh(count - 1), 10);
|
||||
}
|
||||
startAutoRefresh(200);
|
||||
- elsif active_page("/contests") == "active"
|
||||
= @contest.name
|
||||
- if @contest.public
|
||||
a.ms-4.btn.btn-success href="/public/#{@contest.slug}" style="margin-top: -6px;"
|
||||
= t("contests.show.open_public_scoreboard")
|
||||
- else
|
||||
a.ms-4.btn.btn-success.disabled style="margin-top: -6px;"
|
||||
= t("contests.show.public_scoreboard_disabled")
|
||||
- else
|
||||
= @contest.name
|
||||
- else
|
||||
= @title
|
||||
|
||||
- if @contest && active_page("/contests") == "active" && !@nonav
|
||||
= render "contest_nav"
|
||||
|
||||
= yield
|
||||
6
app/views/layouts/blank.html.slim
Normal file
6
app/views/layouts/blank.html.slim
Normal file
@@ -0,0 +1,6 @@
|
||||
doctype html
|
||||
html
|
||||
= render "layouts/header"
|
||||
|
||||
body
|
||||
= yield
|
||||
@@ -1,10 +1,10 @@
|
||||
= render "contest_nav"
|
||||
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
|
||||
.row.mb-4
|
||||
.col
|
||||
.alert.alert-primary
|
||||
= t("messages.index.info")
|
||||
- if @messages.length == 0
|
||||
.alert.alert-warning
|
||||
= t("messages.index.no_messages")
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
.row.mb-3
|
||||
.col.alert.alert-warning
|
||||
= t("puzzles.form.fake_data_recommendation")
|
||||
.row.mb-3
|
||||
.col
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached?
|
||||
- if puzzle.id && puzzle.image.attached?
|
||||
.row.mb-3
|
||||
.col
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
@@ -20,6 +21,13 @@
|
||||
.form-floating
|
||||
= form.number_field :pieces, autocomplete: "off", class: "form-control"
|
||||
= form.label :pieces, class: "required"
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-check.form-switch
|
||||
= form.check_box :hidden, class: "form-check-input"
|
||||
= form.label :hidden
|
||||
.form-text
|
||||
= t("activerecord.attributes.puzzle.hidden_description")
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-text.mb-1
|
||||
@@ -31,7 +39,7 @@
|
||||
function setMaxUploadSize() {
|
||||
const el = document.querySelector('input[type="file"]');
|
||||
el.onchange = function() {
|
||||
if(this.files[0].size > 5.3 * 1024 * 1024) {
|
||||
if(this.files[0].size > 9 * 1024 * 1024) {
|
||||
document.getElementById('image-error-message').style.display = 'block';
|
||||
this.value = "";
|
||||
} else {
|
||||
@@ -41,7 +49,7 @@
|
||||
}
|
||||
|
||||
setMaxUploadSize();
|
||||
.row.mt-4
|
||||
.row.mt-4.mb-5
|
||||
.col
|
||||
- if method == :patch
|
||||
= link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
= render "contest_nav"
|
||||
|
||||
.row.mb-4 style="height: calc(100vh - 280px)"
|
||||
.col.d-flex.flex-column style="height: 100%"
|
||||
|
||||
@@ -7,28 +5,38 @@
|
||||
.col
|
||||
a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
|
||||
| + #{t("helpers.buttons.add")}
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.image")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.name")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.brand")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.pieces")
|
||||
tbody
|
||||
- @puzzles.each do |puzzle|
|
||||
tr.align-middle scope="row"
|
||||
td
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 128px;") if puzzle.image.attached?
|
||||
td
|
||||
= puzzle.name
|
||||
td
|
||||
= puzzle.brand
|
||||
td
|
||||
= puzzle.pieces
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
= t("helpers.buttons.edit")
|
||||
|
||||
.d-flex.flex-column style="overflow-y: auto"
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.image")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.name")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.brand")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.pieces")
|
||||
th
|
||||
= t("activerecord.attributes.puzzle.hidden")
|
||||
tbody
|
||||
- @puzzles.each do |puzzle|
|
||||
tr.align-middle scope="row"
|
||||
td
|
||||
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 128px;") if puzzle.image.attached?
|
||||
td
|
||||
= puzzle.name
|
||||
td
|
||||
= puzzle.brand
|
||||
td
|
||||
= puzzle.pieces
|
||||
td
|
||||
- if puzzle.hidden?
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-square" viewBox="0 0 16 16">
|
||||
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
|
||||
<path d="M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"/>
|
||||
</svg>
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
|
||||
= t("helpers.buttons.edit")
|
||||
@@ -30,13 +30,13 @@
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
|
||||
- if method == :patch
|
||||
h4.mt-5 = t("users.edit.password_section")
|
||||
- if method == :patch
|
||||
h4.mt-5 = t("users.edit.password_section")
|
||||
|
||||
= form_with model: user, method: method do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
|
||||
= form_with model: user, url: user_password_path(user) do |form|
|
||||
.row.mb-3
|
||||
.col
|
||||
.form-floating
|
||||
= form.password_field :password, autocomplete: "off", class: "form-control"
|
||||
= form.label :password, class: "required"
|
||||
= form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"
|
||||
@@ -1,27 +1,39 @@
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| Id
|
||||
th scope="col"
|
||||
| Name
|
||||
th scope="col"
|
||||
| Admin?
|
||||
th scope="col"
|
||||
| # contests
|
||||
tbody
|
||||
- @users.each do |user|
|
||||
tr scope="row"
|
||||
td
|
||||
= user.id
|
||||
td
|
||||
= user.username
|
||||
td
|
||||
= user.admin ? "Yes" : "No"
|
||||
td
|
||||
= user.contests.length
|
||||
|
||||
.row
|
||||
.col
|
||||
.d-flex.flex-row.justify-content-start.align-items-center
|
||||
a.btn.btn-primary href=new_user_path
|
||||
| New user
|
||||
= button_to "Update contestants", "/update_contestants", method: :post, class: "ms-3 btn btn-success"
|
||||
= button_to "Regenerate QR codes", "/regenerate_qrcodes", method: :post, class: "ms-3 btn btn-success"
|
||||
|
||||
- @users.each do |user|
|
||||
- if user.admin
|
||||
h3.mt-5 = "#{user.username} (admin)"
|
||||
- else
|
||||
h3.mt-5 = user.username
|
||||
|
||||
table.table.table-striped.table-hover
|
||||
thead
|
||||
tr
|
||||
th scope="col"
|
||||
| ID
|
||||
th scope="col"
|
||||
| Friendly ID
|
||||
th scope="col"
|
||||
| # Puzzles
|
||||
th scope="col"
|
||||
| # Participants
|
||||
tbody
|
||||
- user.contests.each do |contest|
|
||||
tr scope="row"
|
||||
td
|
||||
= contest.id
|
||||
td
|
||||
= contest.friendly_id
|
||||
td
|
||||
= contest.puzzles.length
|
||||
td
|
||||
= contest.contestants.length
|
||||
td
|
||||
a.btn.btn-sm.btn-secondary href=contest_path(contest)
|
||||
= t("helpers.buttons.open")
|
||||
|
||||
|
||||
73
config/brakeman.ignore
Normal file
73
config/brakeman.ignore
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"ignored_warnings": [
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "00462a5825f8e46fe0b5167b1c822296cb5d8443117790a04966ba059a260f2b",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim",
|
||||
"line": 20,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes",
|
||||
"line": 126,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes",
|
||||
"file": "app/views/contestants/generate_qrcodes.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
},
|
||||
{
|
||||
"warning_type": "Cross-Site Scripting",
|
||||
"warning_code": 2,
|
||||
"fingerprint": "d17a497a9b261007930226914a64e99d6f6237c99cc1c33c88745e1341ac4fb7",
|
||||
"check_name": "CrossSiteScripting",
|
||||
"message": "Unescaped model attribute",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim",
|
||||
"line": 11,
|
||||
"link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
|
||||
"code": "Contest.find(params[:contest_id]).contestants.sort_by do\n contestant.name\n end[((row * 4) + col)].qrcode",
|
||||
"render_path": [
|
||||
{
|
||||
"type": "controller",
|
||||
"class": "ContestantsController",
|
||||
"method": "generate_qrcodes_pdf",
|
||||
"line": 135,
|
||||
"file": "app/controllers/contestants_controller.rb",
|
||||
"rendered": {
|
||||
"name": "contestants/generate_qrcodes_pdf",
|
||||
"file": "app/views/contestants/generate_qrcodes_pdf.html.slim"
|
||||
}
|
||||
}
|
||||
],
|
||||
"location": {
|
||||
"type": "template",
|
||||
"template": "contestants/generate_qrcodes_pdf"
|
||||
},
|
||||
"user_input": "Contest.find(params[:contest_id]).contestants",
|
||||
"confidence": "Weak",
|
||||
"cwe_id": [
|
||||
79
|
||||
],
|
||||
"note": "SVG HTML code is generated by the app"
|
||||
}
|
||||
],
|
||||
"brakeman_version": "7.1.1"
|
||||
}
|
||||
@@ -40,6 +40,8 @@ Rails.application.configure do
|
||||
# Set localhost to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
||||
|
||||
config.action_controller.default_url_options = { host: "192.168.122.105", port: 3000 }
|
||||
|
||||
# Print deprecation notices to the Rails logger.
|
||||
config.active_support.deprecation = :log
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ Rails.application.configure do
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
|
||||
config.action_controller.default_url_options = { host: "puzzle-scoreboard.org" }
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
||||
|
||||
@@ -47,17 +47,25 @@ en:
|
||||
contestant: Participant
|
||||
display_time: Time
|
||||
display_time_from_start: Time since start
|
||||
display_time_from_start_description: Format mm:ss or h:mm:ss
|
||||
display_relative_time: Time for this puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Missing pieces
|
||||
projected_time: Projected time
|
||||
remaining_pieces: Remaining pieces (not completed puzzle)
|
||||
contest:
|
||||
code: Code for onsite judges
|
||||
code_description: Optional. Used for organizers on onsite contests, when printing QR codes on tables
|
||||
duration: Duration
|
||||
duration_description: Format h:mm or hh:mm
|
||||
lang: Language for the public scoreboard
|
||||
name: Name
|
||||
offline_form: Enable the offline participation form
|
||||
offline_form_description: Offline participants will have to fill the form by providing an image taken of the puzzle before starting solving it, and validate their finish time with an upload of an image of the completed puzzle
|
||||
offline_form_warning: Only for single-puzzle contests
|
||||
public: Enable the public scoreboard
|
||||
ranking_mode: Ranking mode (public scoreboard & CSV exports)
|
||||
show_stopwatch: Display the stopwatch
|
||||
team: Team contest
|
||||
team_description: For UI display purposes mainly
|
||||
allow_registration: Allow registration
|
||||
@@ -84,6 +92,8 @@ en:
|
||||
remaining_pieces: Remaining pieces
|
||||
puzzle:
|
||||
brand: Brand
|
||||
hidden: hidden
|
||||
hidden_description: When hidden, a puzzle doesn't appear in the public scoreboard, nor public forms
|
||||
image: Image
|
||||
name: Name
|
||||
pieces: Number of pieces
|
||||
@@ -99,6 +109,8 @@ en:
|
||||
models:
|
||||
completion:
|
||||
attributes:
|
||||
code:
|
||||
mismatch: "Wrong code"
|
||||
contestant_id:
|
||||
taken: "This contestant has already completed the puzzle"
|
||||
display_time_from_start:
|
||||
@@ -110,8 +122,12 @@ 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:
|
||||
blank: Must be filled
|
||||
invalid: Invalid duration
|
||||
name:
|
||||
blank: The contest name cannot be empty
|
||||
contestant:
|
||||
@@ -151,6 +167,9 @@ en:
|
||||
blank: Your email cannot be empty
|
||||
username:
|
||||
blank: Your username cannot be empty
|
||||
taken: This username is already taken
|
||||
password:
|
||||
blank: Your password cannot be empty
|
||||
categories:
|
||||
destroy:
|
||||
notice: Category deleted
|
||||
@@ -163,6 +182,10 @@ en:
|
||||
edit:
|
||||
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
|
||||
title: New completion
|
||||
@@ -174,19 +197,26 @@ en:
|
||||
notice: Contest updated
|
||||
title: Edit contest settings
|
||||
form:
|
||||
categories: Participant categories
|
||||
general: General parameters
|
||||
offline: Offline participation
|
||||
offline_single_puzzle_warning: This is not available for contests with more than one puzzle
|
||||
index:
|
||||
title: Welcome %{username}!
|
||||
manage_contests: Manage my contests
|
||||
new_contest: Create a new contest
|
||||
nav:
|
||||
categories: Participant categories
|
||||
categories_description: Once you add categories, you will be able to assign them to participants on their profiles, and a filter for categories will be available on the public scoreboard
|
||||
general: General
|
||||
online: Online contests
|
||||
onsite: Onsite contests
|
||||
public: Public scoreboard
|
||||
settings: Settings
|
||||
stopwatch: Stopwatch
|
||||
new:
|
||||
notice: Contest added
|
||||
title: New jigsaw puzzle contest
|
||||
scoreboard:
|
||||
all_categories: All categories
|
||||
auto_refresh: Auto refresh (30s)
|
||||
hide_offline: Hide offline participants
|
||||
refresh: Activate auto-refresh (every 5s)
|
||||
title: "%{name}"
|
||||
@@ -200,6 +230,8 @@ en:
|
||||
offline_form_disabled: The offline form is disabled
|
||||
public_scoreboard_disabled: The public scoreboard is disabled
|
||||
url_copied: URL copied to the clipboard
|
||||
stopwatch:
|
||||
info: This stopwatch is used for display in the public scoreboard, when allowed in the settings
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Import participants
|
||||
@@ -210,12 +242,16 @@ en:
|
||||
end_image: End image
|
||||
notice: Participant updated
|
||||
not_finished: Not yet finished
|
||||
no_puzzles_note: No puzzles were added yet
|
||||
offline_participation: Offline participation
|
||||
start_image: Start image
|
||||
title: Participant
|
||||
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?
|
||||
@@ -244,23 +280,36 @@ en:
|
||||
back_to_contestant: "⬅ Back to the participant"
|
||||
confirm: "Confirm"
|
||||
create: "Create"
|
||||
details: Open
|
||||
delete: "Delete"
|
||||
edit: "Edit"
|
||||
end: Click here to submit your completion
|
||||
export: Export
|
||||
generate_qrcodes: Generate QR codes
|
||||
import: CSV Import
|
||||
open: Open
|
||||
open_raw: Open in a raw page
|
||||
refresh: Refresh
|
||||
settings: Settings
|
||||
sign_in: Sign in
|
||||
save: Save
|
||||
save_password: Save password
|
||||
start: Click here to start your participation
|
||||
stopwatch_continue: Continue
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Reset
|
||||
stopwatch_start: Start
|
||||
update: Save modifications
|
||||
field: Field
|
||||
none: No field selected
|
||||
rank: Rank
|
||||
lib:
|
||||
ranking:
|
||||
actual: First by number of pieces assembled, then by time (recommended if unsure)
|
||||
theorical: By time only (projected time calculated with the ppm count)
|
||||
messages:
|
||||
index:
|
||||
info: This section is only used for contests that rely on the connection with Google Meet
|
||||
no_messages: No messages received yet
|
||||
convert:
|
||||
title: New completion
|
||||
@@ -291,7 +340,7 @@ en:
|
||||
title: Edit contest puzzle
|
||||
form:
|
||||
fake_data_recommendation: It is recommended to first enter a fake name and image, and to use the real ones only once the contest starts.
|
||||
file_too_big: File too big! Maximum allowed size is 5M
|
||||
file_too_big: File too big! Maximum allowed size is 8M
|
||||
image_select: Select an image
|
||||
new:
|
||||
notice: Puzzle added
|
||||
@@ -307,7 +356,7 @@ en:
|
||||
notice: Settings updated
|
||||
title: "My settings"
|
||||
general_section: "General settings"
|
||||
password_section: "Change password"
|
||||
password_section: "Password"
|
||||
index:
|
||||
title: "All users"
|
||||
new:
|
||||
|
||||
@@ -18,17 +18,25 @@ fr:
|
||||
contestant_id: Participant.e
|
||||
display_time: Temps
|
||||
display_time_from_start: Temps depuis le début
|
||||
display_time_from_start_description: Format mm:ss ou h:mm:ss
|
||||
display_relative_time: Temps pour ce puzzle
|
||||
puzzle: Puzzle
|
||||
missing_pieces: Pièces manquantes
|
||||
projected_time: Temps projeté
|
||||
remaining_pieces: Pièces restantes (puzzle non fini)
|
||||
contest:
|
||||
code: Code pour les organisateur.ice.s
|
||||
code_description: Optionnel. Utilisé pour les organisateur.ices dans les concours en présentiel lorsque sont imprimés des QR codes à placer sur les tables
|
||||
duration: Durée
|
||||
duration_description: Format h:mm ou hh:mm
|
||||
lang: Langue pour le classement public
|
||||
name: Nom
|
||||
offline_form: Activer le formulaire de participation hors-ligne
|
||||
offline_form_description: Les participant.e.s hors-ligne pourront participer en prenant une photo du puzzle avant de le commencer, puis valider leur temps avec une photo du puzzle une fois complété
|
||||
offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
|
||||
public: Activer le classement public
|
||||
ranking_mode: Mode de classement (classement public & exports CSV)
|
||||
show_stopwatch: Afficher le chronomètre
|
||||
team: Concours par équipes
|
||||
team_description: Principalement pour des raisons d'affichage
|
||||
allow_registration: Autoriser l'inscription via l'interface
|
||||
@@ -55,6 +63,8 @@ fr:
|
||||
remaining_pieces: Pièces restantes
|
||||
puzzle:
|
||||
brand: Marque
|
||||
hidden: Non découvert
|
||||
hidden_description: Un puzzle non découvert n'apparaît pas dans le classement public, ni dans les formulaires publics
|
||||
image: Image
|
||||
name: Nom
|
||||
pieces: Nombre de pièces
|
||||
@@ -70,6 +80,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:
|
||||
@@ -81,8 +93,12 @@ 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:
|
||||
blank: Obligatoire
|
||||
invalid: Durée invalide
|
||||
name:
|
||||
blank: Le nom du concours ne peut pas être vide
|
||||
contestant:
|
||||
@@ -122,6 +138,9 @@ fr:
|
||||
blank: L'email est obligatoire
|
||||
username:
|
||||
blank: Le nom d'utilisateur.ice est obligatoire
|
||||
taken: Ce nom d'utilisateur.ice est déjà utilisé
|
||||
password:
|
||||
blank: Le mot de passe ne peut pas être vide
|
||||
categories:
|
||||
destroy:
|
||||
notice: Catégorie supprimée
|
||||
@@ -134,6 +153,10 @@ fr:
|
||||
edit:
|
||||
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
|
||||
title: Ajout d'une complétion
|
||||
@@ -145,19 +168,26 @@ fr:
|
||||
notice: Concours modifié
|
||||
title: Paramètres du concours
|
||||
form:
|
||||
categories: Catégories de participant.e.s
|
||||
general: Paramètres généraux
|
||||
offline: Participation hors-ligne
|
||||
offline_single_puzzle_warning: Ce n'est pas activable pour les concours avec plusieurs puzzles
|
||||
index:
|
||||
title: Bienvenue %{username} !
|
||||
manage_contests: Mes concours de puzzle
|
||||
new_contest: Créer un nouveau concours
|
||||
nav:
|
||||
categories: Catégories de participant.e.s
|
||||
categories_description: Après avoir ajouté des catégories, elles pourront être attributées aux participant.e.s sur leurs profils, et un filtre sera disponible sur le classement public
|
||||
general: Général
|
||||
online: Concours en ligne
|
||||
onsite: Concours en présentiel
|
||||
public: Classement public
|
||||
settings: Paramètres
|
||||
stopwatch: Chronomètre
|
||||
new:
|
||||
notice: Concours ajouté
|
||||
title: Nouveau concours
|
||||
scoreboard:
|
||||
all_categories: Toutes les catégories
|
||||
auto_refresh: Auto-rafraichissement (30s)
|
||||
hide_offline: Cacher les participant.e.s hors-ligne
|
||||
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
|
||||
title: "%{name}"
|
||||
@@ -171,6 +201,8 @@ fr:
|
||||
offline_form_disabled: Le formulaire hors-ligne n'est pas activé
|
||||
public_scoreboard_disabled: Le classement public n'est pas activé
|
||||
url_copied: L’URL a été copiée dans le presse-papier
|
||||
stopwatch:
|
||||
info: Ce chronomètre est utilisé pour être affiché dans le classement public, quand autorisé dans les paramètres
|
||||
contestants:
|
||||
convert_csv:
|
||||
title: Importer des participant.e.s
|
||||
@@ -181,12 +213,16 @@ fr:
|
||||
end_image: Image de fin
|
||||
notice: Participant.e modifié.e
|
||||
not_finished: Non terminé
|
||||
no_puzzles_note: Aucun puzzle n'a été défini encore pour ce concours
|
||||
offline_participation: Participation hors-ligne
|
||||
start_time: Image de début
|
||||
start_image: Image de début
|
||||
title: Participant.e
|
||||
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 ?
|
||||
@@ -216,22 +252,35 @@ fr:
|
||||
confirm: "Confirmer"
|
||||
create: "Créer"
|
||||
delete: "Supprimer"
|
||||
details: Détails
|
||||
edit: "Modifier"
|
||||
end: Clique ici pour valider ta complétion du puzzle
|
||||
export: Exporter
|
||||
generate_qrcodes: Générer des QR codes
|
||||
import: Importer un CSV
|
||||
open: Détails
|
||||
open: Ouvrir
|
||||
open_raw: Ouvrir dans un format imprimable
|
||||
refresh: Rafraîchir
|
||||
settings: Paramètres
|
||||
sign_in: Se connecter
|
||||
save: Modifier
|
||||
save_password: Modifier le mot de passe
|
||||
start: Clique ici pour démarrer ta participation
|
||||
stopwatch_continue: Reprendre
|
||||
stopwatch_pause: Pause
|
||||
stopwatch_reset: Ré-initialiser
|
||||
stopwatch_start: Démarrer
|
||||
update: Enregistrer les modifications
|
||||
field: Champ
|
||||
none: Aucun champ sélectionné
|
||||
rank: Rang
|
||||
lib:
|
||||
ranking:
|
||||
actual: Par nombre de pièces assemblées, puis par temps (recommandé)
|
||||
theorical: Par temps uniquement (temps projeté calculé à partir de la vitesse d'assemblage)
|
||||
messages:
|
||||
index:
|
||||
info: Cette section n'est pertinente que pour les concours en ligne qui utilisent la connexion depuis Google Meet
|
||||
no_messages: Pas de messages reçus pour le moment
|
||||
convert:
|
||||
title: Ajout d'une complétion
|
||||
@@ -262,7 +311,7 @@ fr:
|
||||
title: Modifier le puzzle
|
||||
form:
|
||||
fake_data_recommendation: Il est recommendé d'entrer de faux noms et images, et de mettre les vrais uniquement quand le concours démarre.
|
||||
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 5M
|
||||
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 8M
|
||||
image_select: Choisis une image
|
||||
new:
|
||||
notice: Puzzle ajouté
|
||||
@@ -278,7 +327,7 @@ fr:
|
||||
notice: Paramètres modifiés
|
||||
title: "Mes paramètres"
|
||||
general_section: "Paramètres globaux"
|
||||
password_section: "Modifier mon mot de passe"
|
||||
password_section: "Mot de passe"
|
||||
index:
|
||||
title: "Tous.tes les utilisateur.ices"
|
||||
new:
|
||||
|
||||
@@ -11,9 +11,18 @@ Rails.application.routes.draw do
|
||||
resources :contests do
|
||||
get "settings/general", to: "contests#settings_general_edit"
|
||||
patch "settings/general", to: "contests#settings_general_update"
|
||||
get "settings/offline", to: "contests#settings_offline_edit"
|
||||
patch "settings/offline", to: "contests#settings_offline_update"
|
||||
get "settings/public", to: "contests#settings_public_edit"
|
||||
patch "settings/public", to: "contests#settings_public_update"
|
||||
get "settings/onsite", to: "contests#settings_onsite_edit"
|
||||
patch "settings/onsite", to: "contests#settings_onsite_update"
|
||||
get "settings/online", to: "contests#settings_online_edit"
|
||||
patch "settings/online", to: "contests#settings_online_update"
|
||||
get "settings/categories", to: "contests#settings_categories_edit"
|
||||
get "stopwatch", to: "contests#stopwatch"
|
||||
post "stopwatch_continue", to: "contests#stopwatch_continue"
|
||||
post "stopwatch_pause", to: "contests#stopwatch_pause"
|
||||
post "stopwatch_reset", to: "contests#stopwatch_reset"
|
||||
post "stopwatch_start", to: "contests#stopwatch_start"
|
||||
resources :categories, only: [ :create, :destroy ]
|
||||
resources :completions
|
||||
resources :contestants
|
||||
@@ -26,20 +35,50 @@ Rails.application.routes.draw do
|
||||
get "import/:id", to: "contestants#convert_csv"
|
||||
post "import/:id", to: "contestants#finalize_import"
|
||||
get "export", to: "contestants#export"
|
||||
get "generate_qrcodes", to: "contestants#generate_qrcodes"
|
||||
get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf"
|
||||
end
|
||||
resources :passwords, param: :token
|
||||
resource :session
|
||||
resources :users
|
||||
resources :users do
|
||||
patch "password", to: "users#change_password"
|
||||
end
|
||||
|
||||
options "connect", to: "messages#cors_preflight_check"
|
||||
options "message", to: "messages#cors_preflight_check"
|
||||
post "connect", to: "messages#connect"
|
||||
post "message", to: "messages#create"
|
||||
|
||||
post "regenerate_qrcodes", to: "users#regenerate_qrcodes"
|
||||
post "update_contestants", to: "users#update_contestants"
|
||||
|
||||
get "public/:id", to: "contests#scoreboard"
|
||||
get "public/:id/offline", to: "contests#offline_new"
|
||||
post "public/:id/offline", to: "contests#offline_create"
|
||||
get "public/:id/offline/:token", to: "contests#offline_edit"
|
||||
patch "public/:id/offline/:token", to: "contests#offline_update"
|
||||
get "public/:id/offline/:token/completed", to: "contests#offline_completed"
|
||||
get "public/p/:contestant_id", to: "contestants#get_public_completion"
|
||||
post "public/p/:contestant_id", to: "contestants#post_public_completion"
|
||||
get "public/p/:contestant_id/updated", to: "contestants#public_completion_updated"
|
||||
|
||||
direct :direct_test do
|
||||
"https://lol.com"
|
||||
end
|
||||
|
||||
direct :public_scoreboard do |contest|
|
||||
"/public/#{contest.friendly_id}/public"
|
||||
end
|
||||
|
||||
direct :offline_form do |contest|
|
||||
"/public/#{contest.friendly_id}/offline"
|
||||
end
|
||||
|
||||
direct :offline_form_edit do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}"
|
||||
end
|
||||
|
||||
direct :offline_form_completed do |contest, offline|
|
||||
"/public/#{contest.friendly_id}/offline/#{offline.generate_token_for(:token)}/completed"
|
||||
end
|
||||
end
|
||||
|
||||
5
db/migrate/20251114085123_add_ranking_mode_to_contest.rb
Normal file
5
db/migrate/20251114085123_add_ranking_mode_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddRankingModeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :ranking_mode, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddProjectedTimeToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :projected_time, :integer
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251114093213_add_completion_to_offline.rb
Normal file
5
db/migrate/20251114093213_add_completion_to_offline.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCompletionToOffline < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_reference :offlines, :completion, foreign_key: true
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251118074900_add_duration_to_contest.rb
Normal file
5
db/migrate/20251118074900_add_duration_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddDurationToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :duration, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddDurationSecondsToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :duration_seconds, :integer
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120100813_add_qrcode_to_contestant.rb
Normal file
5
db/migrate/20251120100813_add_qrcode_to_contestant.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddQrcodeToContestant < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contestants, :qrcode, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120150744_add_code_to_contest.rb
Normal file
5
db/migrate/20251120150744_add_code_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCodeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :code, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251120152211_add_code_to_completion.rb
Normal file
5
db/migrate/20251120152211_add_code_to_completion.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddCodeToCompletion < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :completions, :code, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251204100550_add_hidden_to_puzzle.rb
Normal file
5
db/migrate/20251204100550_add_hidden_to_puzzle.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddHiddenToPuzzle < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :puzzles, :hidden, :boolean
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251209073711_add_start_time_to_contest.rb
Normal file
5
db/migrate/20251209073711_add_start_time_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddStartTimeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :start_time, :datetime
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251209075739_add_pause_time_to_contest.rb
Normal file
5
db/migrate/20251209075739_add_pause_time_to_contest.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddPauseTimeToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :pause_time, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddShowStopwatchToContest < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :contests, :show_stopwatch, :boolean
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddPasswordChangeAttemptToUser < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :users, :password_change_attempt, :boolean
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
generated
18
db/schema.rb
generated
@@ -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_10_110151) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_12_10_092658) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -67,6 +67,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.integer "remaining_pieces"
|
||||
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"
|
||||
@@ -81,6 +83,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "display_time"
|
||||
t.integer "time_seconds"
|
||||
t.string "projected_time"
|
||||
t.string "qrcode"
|
||||
t.index ["contest_id"], name: "index_contestants_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -95,6 +99,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.string "lang", default: "en"
|
||||
t.boolean "public", default: false
|
||||
t.boolean "offline_form", default: false
|
||||
t.string "ranking_mode"
|
||||
t.string "duration"
|
||||
t.integer "duration_seconds"
|
||||
t.string "code"
|
||||
t.datetime "start_time"
|
||||
t.datetime "pause_time"
|
||||
t.boolean "show_stopwatch"
|
||||
t.index ["slug"], name: "index_contests_on_slug", unique: true
|
||||
t.index ["user_id"], name: "index_contests_on_user_id"
|
||||
end
|
||||
@@ -140,6 +151,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.integer "missing_pieces"
|
||||
t.integer "remaining_pieces"
|
||||
t.boolean "submitted"
|
||||
t.integer "completion_id"
|
||||
t.index ["completion_id"], name: "index_offlines_on_completion_id"
|
||||
t.index ["contest_id"], name: "index_offlines_on_contest_id"
|
||||
t.index ["contestant_id"], name: "index_offlines_on_contestant_id"
|
||||
end
|
||||
@@ -151,6 +164,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.integer "contest_id", null: false
|
||||
t.string "brand"
|
||||
t.integer "pieces", null: false
|
||||
t.boolean "hidden"
|
||||
t.index ["contest_id"], name: "index_puzzles_on_contest_id"
|
||||
end
|
||||
|
||||
@@ -171,6 +185,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
t.string "username"
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.string "lang", default: "en"
|
||||
t.boolean "password_change_attempt"
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
end
|
||||
|
||||
@@ -184,6 +199,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
|
||||
add_foreign_key "contestants", "contests"
|
||||
add_foreign_key "contests", "users"
|
||||
add_foreign_key "messages", "contests"
|
||||
add_foreign_key "offlines", "completions"
|
||||
add_foreign_key "offlines", "contestants"
|
||||
add_foreign_key "offlines", "contests"
|
||||
add_foreign_key "puzzles", "contests"
|
||||
|
||||
29
spec/factories/contestant.rb
Normal file
29
spec/factories/contestant.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: contestants
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# display_time :string
|
||||
# email :string
|
||||
# name :string
|
||||
# projected_time :string
|
||||
# qrcode :string
|
||||
# time_seconds :integer
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_contestants_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
FactoryBot.define do
|
||||
factory :contestant do
|
||||
name { Faker::Name.name }
|
||||
email { Faker::Internet.unique.email }
|
||||
end
|
||||
end
|
||||
@@ -4,11 +4,18 @@
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# allow_registration :boolean default(FALSE)
|
||||
# code :string
|
||||
# duration :string
|
||||
# duration_seconds :integer
|
||||
# lang :string default("en")
|
||||
# name :string
|
||||
# offline_form :boolean default(FALSE)
|
||||
# pause_time :datetime
|
||||
# public :boolean default(FALSE)
|
||||
# ranking_mode :string
|
||||
# show_stopwatch :boolean
|
||||
# slug :string
|
||||
# start_time :datetime
|
||||
# team :boolean default(FALSE)
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -26,5 +33,11 @@
|
||||
FactoryBot.define do
|
||||
factory :contest do
|
||||
name { Faker::Company.unique.name }
|
||||
duration { "2:00" }
|
||||
ranking_mode { "actual" }
|
||||
end
|
||||
|
||||
trait :offline do
|
||||
offline_form { true }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,16 +12,19 @@
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# completion_id :integer
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_completion_id (completion_id)
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# completion_id (completion_id => completions.id)
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -20,7 +20,7 @@ RSpec.feature "Contests", type: :feature do
|
||||
|
||||
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click
|
||||
|
||||
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}")
|
||||
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}/contestants")
|
||||
end
|
||||
|
||||
it "should offer to create a new contest" do
|
||||
@@ -48,31 +48,7 @@ RSpec.feature "Contests", type: :feature do
|
||||
|
||||
click_button I18n.t("helpers.buttons.create")
|
||||
|
||||
expect(page).to have_current_path(contest_path(Contest.find_by(name: "Contest name")))
|
||||
end
|
||||
end
|
||||
|
||||
context "edit" do
|
||||
let!(:contest) { create(:contest, user: user) }
|
||||
|
||||
it "should prevent editing contests without a name" do
|
||||
visit edit_contest_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.contest.name"), with: ""
|
||||
|
||||
click_button I18n.t("helpers.buttons.save")
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
|
||||
end
|
||||
|
||||
it "should allow editing contests with valid parameters" do
|
||||
visit edit_contest_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
|
||||
|
||||
click_button I18n.t("helpers.buttons.save")
|
||||
|
||||
expect(page).to have_current_path(contest_path(contest))
|
||||
expect(page).to have_current_path("/contests")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ RSpec.feature "Login", type: :feature do
|
||||
fill_in "Password", with: user.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
expect(page).to have_content("Puzzle scoreboard")
|
||||
end
|
||||
|
||||
it "should fail to log in the user with an incorrect password" do
|
||||
@@ -28,7 +28,7 @@ RSpec.feature "Login", type: :feature do
|
||||
fill_in "Password", with: Faker::Internet.unique.password
|
||||
click_button "Sign in"
|
||||
|
||||
expect(page).to have_content(I18n.t("sessions.new.title"))
|
||||
expect(page).to have_content("Puzzle scoreboard")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
145
spec/features/offline_spec.rb
Normal file
145
spec/features/offline_spec.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.feature "Users", type: :feature do
|
||||
context "when the contest doesn't allow offline participation" do
|
||||
let!(:contest) { create(:contest, user: create(:user)) }
|
||||
|
||||
it "shouldn't be possible to load the offline participation form" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
expect(page).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the contest allows offline participation" do
|
||||
let!(:contest) { create(:contest, :offline, user: create(:user)) }
|
||||
|
||||
it "should be possible to load the offline participation form" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_content(contest.name)
|
||||
end
|
||||
|
||||
it "shouldn't be possible to validate the form without a pseudo" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: ""
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.name.blank"))
|
||||
end
|
||||
|
||||
it "shouldn't be possible to validate the form without an image" do
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.start") }.not_to change { contest.reload.offlines.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.start_image.blank"))
|
||||
end
|
||||
|
||||
it "should be possible to start the offline participation with a valid name and start image" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.start") }.to change { contest.reload.offlines.size }.to(1)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_edit_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "shouldn't be possible to complete the offline participation without an end image" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.not_to change { contest.offlines[0].images.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.end_image.blank"))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "shouldn't be possible to complete the offline participation without remaining pieces count when the puzzle isn't completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.not_to change { contest.offlines[0].images.size }
|
||||
expect(page).to have_http_status(422)
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.offline.attributes.remaining_pieces.blank"))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "should be possible to complete the offline participation with the end image and remaining pieces count when the puzzle isn't completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
end_image_file = Tempfile.new('end_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.remaining_pieces"), with: "10"
|
||||
attach_file("offline[end_image]", end_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.to change { contest.offlines[0].images.size }.to(2)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_completed_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end_image_file.close
|
||||
end_image_file.unlink
|
||||
end
|
||||
end
|
||||
|
||||
it "should be possible to complete the offline participation with the end image solely when the puzzle is completed" do
|
||||
start_image_file = Tempfile.new('start_image')
|
||||
end_image_file = Tempfile.new('end_image')
|
||||
begin
|
||||
visit offline_form_path(contest)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.offline.name"), with: "my_name"
|
||||
attach_file("offline[images]", start_image_file.path)
|
||||
click_button I18n.t("helpers.buttons.start")
|
||||
|
||||
check I18n.t("activerecord.attributes.offline.completed")
|
||||
attach_file("offline[end_image]", end_image_file.path)
|
||||
|
||||
expect { click_button I18n.t("helpers.buttons.end") }.to change { contest.offlines[0].images.size }.to(2)
|
||||
expect(page).to have_http_status(200)
|
||||
expect(page).to have_current_path(offline_form_completed_path(contest, contest.offlines[0]))
|
||||
ensure
|
||||
start_image_file.close
|
||||
start_image_file.unlink
|
||||
end_image_file.close
|
||||
end_image_file.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,21 +21,54 @@ RSpec.feature "Users", type: :feature do
|
||||
expect(page).not_to have_content(I18n.t("users.index.title"))
|
||||
end
|
||||
|
||||
it "should be able to create a new contest" do
|
||||
it "should be able to open their account info" do
|
||||
visit root_path
|
||||
|
||||
click_link "Create a new contest"
|
||||
click_link I18n.t("nav.settings")
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.new.title"))
|
||||
expect(page).to have_current_path(edit_user_path(user))
|
||||
end
|
||||
|
||||
it "should be able to open an existing contest" do
|
||||
visit root_path
|
||||
context "when updating their account info" do
|
||||
let!(:existing_user) { create(:user, username: "taken_username") }
|
||||
|
||||
expect(page).to have_content(contest.name)
|
||||
find("div.card", text: contest.name).find("a").click
|
||||
it "should allow changing to an untaken username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: "untaken_username"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.to change(user, :username).to("untaken_username")
|
||||
end
|
||||
|
||||
it "should prevent changing to an already taken username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: "taken_username"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.taken"))
|
||||
end
|
||||
|
||||
it "should prevent changing to a blank username" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.username"), with: ""
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save")); user.reload }.not_to change(user, :username)
|
||||
|
||||
expect(page).to have_content(I18n.t("activerecord.errors.models.user.attributes.username.blank"))
|
||||
end
|
||||
|
||||
it "should allow changing to a non-blank password" do
|
||||
visit edit_user_path(user)
|
||||
|
||||
fill_in I18n.t("activerecord.attributes.user.password"), with: "new_password"
|
||||
|
||||
expect { click_button(I18n.t("helpers.buttons.save_password")); user.reload }
|
||||
.to change(user, :password_digest)
|
||||
.and change { user.authenticate("new_password") }.from(false).to(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: categories
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# name :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_categories_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Category, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# 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
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CsvImport, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: messages
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# author :string
|
||||
# display_time :string
|
||||
# text :string not null
|
||||
# time_seconds :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_messages_on_contest_id (contest_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Message, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: offlines
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# completed :boolean
|
||||
# end_time :datetime
|
||||
# missing_pieces :integer
|
||||
# name :string not null
|
||||
# remaining_pieces :integer
|
||||
# start_time :datetime not null
|
||||
# submitted :boolean
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contest_id :integer not null
|
||||
# contestant_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_offlines_on_contest_id (contest_id)
|
||||
# index_offlines_on_contestant_id (contestant_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# contest_id (contest_id => contests.id)
|
||||
# contestant_id (contestant_id => contestants.id)
|
||||
#
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Offline, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
@@ -2,14 +2,15 @@
|
||||
#
|
||||
# Table name: users
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :integer not null, primary key
|
||||
# admin :boolean default(FALSE), not null
|
||||
# email_address :string not null
|
||||
# lang :string default("en")
|
||||
# password_change_attempt :boolean
|
||||
# password_digest :string not null
|
||||
# username :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -67,4 +67,6 @@ RSpec.configure do |config|
|
||||
config.filter_rails_from_backtrace!
|
||||
# arbitrary gems may also be filtered via:
|
||||
# config.filter_gems_from_backtrace("gem name")
|
||||
|
||||
config.include SoManyDevices::DownloadsHelper, type: :system
|
||||
end
|
||||
|
||||
40
spec/system/export_spec.rb
Normal file
40
spec/system/export_spec.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
=begin Keeping this commented until resolving the ChromeDriver Gitea runner issue.
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe "Exports", type: :system do
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
driven_by :selenium_chrome_with_download_headless
|
||||
end
|
||||
|
||||
after do
|
||||
clear_downloads
|
||||
end
|
||||
|
||||
context "when in a contest with at least one contestant" do
|
||||
let!(:contest) { create(:contest, user: user) }
|
||||
let!(:first_contestant) { create(:contestant, contest: contest) }
|
||||
let!(:second_contestant) { create(:contestant, contest: contest) }
|
||||
|
||||
it "should be possible to export the list of contestants", :with_downloads do
|
||||
login(user)
|
||||
|
||||
sleep 0.5
|
||||
|
||||
visit contest_contestants_path(contest)
|
||||
|
||||
click_link I18n.t("helpers.buttons.export")
|
||||
|
||||
wait_for_download
|
||||
|
||||
expect(downloads.length).to eq(1)
|
||||
expect(last_download).to include("#{contest.friendly_id}_results.csv")
|
||||
|
||||
results_csv = File.read(last_download)
|
||||
expect(results_csv).to include(first_contestant.name)
|
||||
expect(results_csv).to include(second_contestant.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
=end
|
||||
Reference in New Issue
Block a user