Compare commits

..

48 Commits

Author SHA1 Message Date
sto
279e7eaf3f Comment export spec until resolving the ChromeDriver Gitea runner issue
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-12-12 11:05:07 +01:00
sto
683d99ab12 System test for CSV export
Some checks failed
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 1m28s
#5
2025-12-12 10:31:03 +01:00
sto
76553d4cbc Fix CSV export
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 40s
2025-12-11 17:27:34 +01:00
sto
31fe8789ce Up the image file limit to 8M
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 40s
2025-12-11 09:18:15 +01:00
sto
360157e0c8 Permit password creation
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 38s
2025-12-10 17:50:07 +01:00
sto
5345e419df Fix password change
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 40s
2025-12-10 17:43:00 +01:00
sto
2c87a5b63c Add url helpers & specs for offline participation forms
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 38s
#5
2025-12-10 15:12:19 +01:00
sto
8cea403dc9 Fix account page forms & add account actions rspec
All checks were successful
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
#5
2025-12-10 10:44:03 +01:00
sto
cce090587a Public scoreboard stopwatch feature
All checks were successful
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
#10
2025-12-09 10:10:54 +01:00
sto
ee250b96ad Auto refresh feature on public scoreboards
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 38s
#12
#13
2025-12-08 17:11:47 +01:00
sto
1fc05bea63 Split scoreboard UI in partials
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
#13
2025-12-08 09:49:24 +01:00
sto
7bd1dce1ea Add extra nav for settings & clean header buttons
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-12-05 10:53:52 +01:00
sto
e2c50515b1 Redirect to /contests after authentication
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 35s
#25
2025-12-04 17:31:06 +01:00
sto
51e55f0828 Add hidden setting to puzzles
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
#19
2025-12-04 11:35:41 +01:00
sto
a2a8a9fcef Make public contestant forms activated only when organizers code are set
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 35s
#20
2025-12-04 10:50:10 +01:00
sto
d08370f5f8 Use contestant IDs instead of tokens for QR codes
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 37s
#6
2025-12-04 10:30:44 +01:00
sto
cd41d83429 Fix rspec as is
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 36s
#18
2025-12-03 15:42:45 +01:00
sto
3a6ee2ea98 Add QR codes inline HTML in Brakeman ignore list
Some checks failed
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 39s
#18
2025-12-03 15:29:34 +01:00
sto
d01775471e Display time per puzzle for marathon scoreboards
Some checks failed
CI / scan_ruby (push) Failing after 21s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 36s
2025-11-27 16:26:37 +01:00
sto
66d968fca8 Add admin action to checkout contests
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 39s
2025-11-26 10:44:30 +01:00
sto
7a80c434af Fix number of QR codes shown
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 1m54s
2025-11-25 09:58:14 +01:00
sto
768af7c3e9 Remove PDF generation and make HTML one printable
Some checks failed
CI / scan_ruby (push) Failing after 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 39s
2025-11-24 12:04:08 +01:00
sto
c0f2358a36 Install chromium for prod + extend process timeout
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 40s
2025-11-24 11:41:51 +01:00
sto
7a64fa181a Add PDF generation for QR codes
Some checks failed
CI / scan_ruby (push) Failing after 24s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-24 10:58:26 +01:00
sto
024b254808 Fix QR codes generation on 'finalize_import' action
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 36s
2025-11-24 10:07:48 +01:00
sto
6ec4a89907 Add title on new sessions
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 35s
2025-11-24 09:57:30 +01:00
sto
d59090cded Improve admin UI
Some checks failed
CI / scan_ruby (push) Failing after 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 32s
2025-11-22 09:06:10 +01:00
sto
ae3c7c73e1 Add admin action to regenerate QR codes
Some checks failed
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Failing after 10s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-21 10:53:23 +01:00
sto
db6f732e63 Fix prod name
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 37s
2025-11-21 10:47:17 +01:00
sto
fdd47c231a Reset the default_utl_options
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 41s
2025-11-21 10:43:40 +01:00
sto
b2429b71f4 Rollback prod config change
Some checks failed
CI / scan_ruby (push) Failing after 37s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 41s
2025-11-21 10:39:16 +01:00
sto
710953919c Allow offline participants to take photos from their devices
Some checks failed
CI / scan_ruby (push) Failing after 23s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 43s
2025-11-21 10:08:51 +01:00
sto
94e725d20a Add judges codes
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 45s
2025-11-20 16:59:30 +01:00
sto
b43a801e3c Generate QR codes when not present and asked for
Some checks failed
CI / scan_ruby (push) Failing after 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Failing after 42s
2025-11-20 15:50:50 +01:00
sto
c4f0f603f6 Remove useless team contest setting
Some checks failed
CI / scan_ruby (push) Failing after 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 43s
2025-11-20 15:44:27 +01:00
sto
709719b801 QR codes generation
Some checks failed
CI / scan_ruby (push) Failing after 31s
CI / scan_js (push) Successful in 16s
CI / lint (push) Successful in 16s
CI / test (push) Failing after 45s
2025-11-20 12:01:30 +01:00
sto
3e071f9281 Add public form to add completions
Some checks failed
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 16s
CI / test (push) Failing after 43s
2025-11-20 10:24:26 +01:00
sto
b87800f6bd Correct category selector URL
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 26s
2025-11-18 12:02:14 +01:00
sto
bf127bb932 Completions: notice when no puzzles defined
Some checks failed
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 39s
2025-11-18 11:57:48 +01:00
sto
3dd153d587 Require contest durations, prefill end times for unfinished puzzles & allow to modify them
Some checks failed
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 44s
2025-11-18 11:46:02 +01:00
sto
63a88ea113 Add method to update contestants through admin UI
Some checks failed
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-18 11:15:23 +01:00
sto
ecb36e19ed Reorder general settings
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-18 09:38:15 +01:00
sto
bc96b16bcb Improve redirections after puzzles/messages controllers
Some checks failed
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 34s
2025-11-18 09:33:42 +01:00
sto
0f725e2eef Fix contest creation
Some checks failed
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Failing after 35s
2025-11-18 09:30:40 +01:00
sto
e67ee92838 Add contest duration & complete ranking mode implementation
Some checks failed
CI / scan_ruby (push) Successful in 21s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 37s
2025-11-18 09:18:18 +01:00
sto
b88460ae71 Implement projected time
Some checks failed
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Failing after 35s
2025-11-14 12:13:09 +01:00
sto
f91145637f Add ranking mode
Some checks failed
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 35s
2025-11-14 10:19:08 +01:00
sto
cdf87e48f2 Merge settings & core indexes into a single nav 2025-11-13 18:21:31 +01:00
92 changed files with 1835 additions and 795 deletions

View File

@@ -16,7 +16,7 @@ WORKDIR /rails
# Install base packages # Install base packages
RUN apt-get update -qq && \ 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 rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment # Set production environment

View File

@@ -46,6 +46,7 @@ gem "bootstrap", "~> 5.3.3"
gem "friendly_id", "~> 5.5.0" gem "friendly_id", "~> 5.5.0"
gem "csv" gem "csv"
gem "damerau-levenshtein" gem "damerau-levenshtein"
gem "rqrcode", "~> 3.0"
group :development, :test do group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem # 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] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
gem "so_many_devices"
end end
gem "pundit", "~> 2.5" gem "pundit", "~> 2.5"

View File

@@ -84,7 +84,7 @@ GEM
benchmark (0.5.0) benchmark (0.5.0)
bigdecimal (3.3.1) bigdecimal (3.3.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.6) bootsnap (1.19.0)
msgpack (~> 1.2) msgpack (~> 1.2)
bootstrap (5.3.5) bootstrap (5.3.5)
popper_js (>= 2.11.8, < 3) popper_js (>= 2.11.8, < 3)
@@ -100,6 +100,7 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.4) connection_pool (2.5.4)
crass (1.0.6) crass (1.0.6)
@@ -116,7 +117,7 @@ GEM
dotenv (3.1.8) dotenv (3.1.8)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (5.1.3) erb (6.0.0)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
@@ -134,19 +135,19 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.33.0) google-protobuf (4.33.1)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.33.0-aarch64-linux-gnu) google-protobuf (4.33.1-aarch64-linux-gnu)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.33.0-aarch64-linux-musl) google-protobuf (4.33.1-aarch64-linux-musl)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.33.0-x86_64-linux-gnu) google-protobuf (4.33.1-x86_64-linux-gnu)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.33.0-x86_64-linux-musl) google-protobuf (4.33.1-x86_64-linux-musl)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
i18n (1.14.7) i18n (1.14.7)
@@ -163,7 +164,7 @@ GEM
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.15.2) json (2.16.0)
kamal (2.8.2) kamal (2.8.2)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -190,7 +191,7 @@ GEM
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.26.0) minitest (5.26.2)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.12) net-imap (0.5.12)
date date
@@ -288,9 +289,13 @@ GEM
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.2) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) 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-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.5) rspec-expectations (3.13.5)
@@ -319,14 +324,14 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1) rubocop-ast (1.48.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.4)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4) rubocop-rails (2.34.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
@@ -338,17 +343,17 @@ GEM
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rubyzip (3.2.2) rubyzip (3.2.2)
sass-embedded (1.93.3-aarch64-linux-gnu) sass-embedded (1.94.2-aarch64-linux-gnu)
google-protobuf (~> 4.31) google-protobuf (~> 4.31)
sass-embedded (1.93.3-aarch64-linux-musl) sass-embedded (1.94.2-aarch64-linux-musl)
google-protobuf (~> 4.31) google-protobuf (~> 4.31)
sass-embedded (1.93.3-arm-linux-gnueabihf) sass-embedded (1.94.2-arm-linux-gnueabihf)
google-protobuf (~> 4.31) google-protobuf (~> 4.31)
sass-embedded (1.93.3-arm-linux-musleabihf) sass-embedded (1.94.2-arm-linux-musleabihf)
google-protobuf (~> 4.31) 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) 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) google-protobuf (~> 4.31)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.38.0) selenium-webdriver (4.38.0)
@@ -360,12 +365,14 @@ GEM
slim (5.2.1) slim (5.2.1)
temple (~> 0.10.0) temple (~> 0.10.0)
tilt (>= 2.1.0) tilt (>= 2.1.0)
so_many_devices (1.0.0)
capybara (>= 3.0)
solid_cable (3.0.12) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_cache (1.0.9) solid_cache (1.0.10)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
@@ -391,7 +398,7 @@ GEM
ostruct ostruct
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) stringio (3.1.8)
temple (0.10.4) temple (0.10.4)
thor (1.4.0) thor (1.4.0)
thruster (0.1.16) thruster (0.1.16)
@@ -455,10 +462,12 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
pundit (~> 2.5) pundit (~> 2.5)
rails (~> 8.0.2) rails (~> 8.0.2)
rqrcode (~> 3.0)
rspec-rails rspec-rails
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver
slim slim
so_many_devices
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue

View File

@@ -2,7 +2,7 @@ class ApplicationController < ActionController::Base
include Authentication include Authentication
include Pundit::Authorization include Pundit::Authorization
before_action :set_title, :set_current_user, :set_lang before_action :set_current_user, :set_lang
after_action :verify_authorized after_action :verify_authorized
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # 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 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 def set_current_user
@current_user = current_user @current_user = current_user
end end

View File

@@ -8,21 +8,11 @@ class CompletionsController < ApplicationController
def edit def edit
authorize @contest authorize @contest
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
end end
def new def new
authorize @contest 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 = Completion.new
@completion.completed = true @completion.completed = true
if params[:contestant_id] if params[:contestant_id]
@@ -34,11 +24,9 @@ class CompletionsController < ApplicationController
authorize @contest authorize @contest
@completion = Completion.new(completion_params) @completion = Completion.new(completion_params)
@completion.contest_id = @contest.id @completion.contest = @contest
if @completion.save if @completion.save
if @completion.display_time_from_start.present?
extend_completions!(@completion.contestant) extend_completions!(@completion.contestant)
end
if @contestant && !params[:completion].key?(:message_id) if @contestant && !params[:completion].key?(:message_id)
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice") redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
else else
@@ -47,11 +35,6 @@ class CompletionsController < ApplicationController
else else
if params[:completion].key?(:message_id) if params[:completion].key?(:message_id)
@message = Message.find(params[:completion][: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 end
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
@@ -69,10 +52,6 @@ class CompletionsController < ApplicationController
redirect_to @contest, notice: t("completions.edit.notice") redirect_to @contest, notice: t("completions.edit.notice")
end end
else 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 render :edit, status: :unprocessable_entity
end end
end end

View File

@@ -35,7 +35,7 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || "/contests"
end end
def start_new_session_for(user) def start_new_session_for(user)

View File

@@ -9,24 +9,38 @@ module CompletionsConcern
end end
def display_time(time) def display_time(time)
if time == nil
return ""
end
h = time / 3600 h = time / 3600
m = (time % 3600) / 60 m = (time % 3600) / 60
s = (time % 3600) % 60 s = (time % 3600) % 60
if h > 0 if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s) return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end end
s.to_s m.to_s + ":" + pad(s)
end end
def extend_completions!(contestant) def extend_completions!(contestant)
completions = contestant.completions
puzzles = contestant.contest.puzzles
if puzzles.length > 1
current_time_from_start = 0 current_time_from_start = 0
contestant.completions.order(:time_seconds).each do |completion| completions.order(:time_seconds).each do |completion|
completion.update(display_time_from_start: display_time(completion.time_seconds), completion.update(display_time_from_start: display_time(completion.time_seconds),
display_relative_time: display_time(completion.time_seconds - current_time_from_start)) display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds current_time_from_start = completion.time_seconds
end end
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start) 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
end end
end end

View 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

View File

@@ -1,34 +1,26 @@
class ContestantsController < ApplicationController 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_contestant, only: %i[ destroy edit update]
before_action :set_completions, only: %i[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 def index
authorize @contest authorize @contest
@title = @contest.name @contestants = @contest.contestants.sort_by { |contestant| contestant.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 filter_contestants_per_category
end end
def edit def edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = @contestant.name
end end
def new def new
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@contestant = Contestant.new @contestant = Contestant.new
end end
@@ -41,8 +33,6 @@ class ContestantsController < ApplicationController
update_contestant_categories update_contestant_categories
redirect_to contest_path(@contest), notice: t("contestants.new.notice") redirect_to contest_path(@contest), notice: t("contestants.new.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -52,10 +42,8 @@ class ContestantsController < ApplicationController
if @contestant.update(contestant_params) if @contestant.update(contestant_params)
update_contestant_categories update_contestant_categories
redirect_to @contest, notice: t("contestants.edit.notice") redirect_to contest_contestants_path(@contest), notice: t("contestants.edit.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -83,8 +71,6 @@ class ContestantsController < ApplicationController
if @csv_import.save if @csv_import.save
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}" redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :import, status: :unprocessable_entity render :import, status: :unprocessable_entity
end end
end end
@@ -92,8 +78,6 @@ class ContestantsController < ApplicationController
def convert_csv def convert_csv
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.find(params[:id]) @csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content) @content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new @form = Forms::CsvConversionForm.new
@@ -112,15 +96,12 @@ class ContestantsController < ApplicationController
if @form.email_column == -1 if @form.email_column == -1
Contestant.create(name: row[@form.name_column], contest: @contest) Contestant.create(name: row[@form.name_column], contest: @contest)
else else
logger.info("Email")
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest) Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
end end
end end
end end
redirect_to contest_path(@contest), notice: t("contestants.import.notice") redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :convert_csv, status: :unprocessable_entity render :convert_csv, status: :unprocessable_entity
end end
end end
@@ -128,21 +109,94 @@ class ContestantsController < ApplicationController
def export def export
authorize @contest authorize @contest
@contestants = @contest.contestants.sort_by { |contestant| [ @contestants = ranked_contestants(@contest)
-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
] }
respond_to do |format| respond_to do |format|
format.csv do format.csv do
response.headers["Content-Type"] = "text/csv" 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 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 private
def set_contest def set_contest
@@ -181,4 +235,15 @@ class ContestantsController < ApplicationController
end end
end 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 end

View File

@@ -1,8 +1,9 @@
class ContestsController < ApplicationController class ContestsController < ApplicationController
include CompletionsConcern include CompletionsConcern
include ContestantsConcern
before_action :set_contest, only: %i[ destroy edit show update ] before_action :set_contest, only: %i[ destroy show ]
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_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 ] 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 ] 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) redirect_to contest_contestants_path(@contest)
end end
def edit
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end
def settings_general_edit def settings_general_edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
end end
def settings_offline_edit def settings_public_edit
authorize @contest authorize @contest
end
@action_name = t("helpers.buttons.back") def settings_onsite_edit
@action_path = contest_path(@contest) authorize @contest
@title = t("contests.edit.title") end
def settings_online_edit
authorize @contest
end end
def settings_categories_edit def settings_categories_edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
end end
def settings_general_update def settings_general_update
@@ -56,56 +46,105 @@ class ContestsController < ApplicationController
if @contest.update(settings_general_params) if @contest.update(settings_general_params)
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice") redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.edit.notice")
else else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
render :settings_general_edit, status: :unprocessable_entity render :settings_general_edit, status: :unprocessable_entity
end end
end end
def settings_offline_update def settings_public_update
authorize @contest authorize @contest
if @contest.update(settings_offline_params) if @contest.update(settings_public_params)
redirect_to "/contests/#{@contest.id}/settings/offline", notice: t("contests.edit.notice") redirect_to "/contests/#{@contest.id}/settings/public", notice: t("contests.edit.notice")
else else
@action_name = t("helpers.buttons.back") render :settings_public_edit, status: :unprocessable_entity
@action_path = contest_path(@contest)
@title = t("contests.edit.title")
render :settings_offline_edit, status: :unprocessable_entity
end end
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 def new
authorize :contest authorize :contest
@contest = Contest.new @contest = Contest.new
@title = I18n.t("contests.new.title")
@nonav = true
end end
def create def create
authorize :contest 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 @contest.user_id = current_user.id
if @contest.save if @contest.save
redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice") redirect_to "/contests/#{@contest.id}/settings/general", notice: t("contests.new.notice")
else else
@title = I18n.t("contests.new.title")
@nonav = true
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
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 def destroy
authorize @contest authorize @contest
@@ -124,23 +163,20 @@ class ContestsController < ApplicationController
I18n.locale = @contest.lang I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name) @title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @contest.contestants.sort_by { |contestant| [ @contestants = ranked_contestants(@contest)
-contestant.completions.where(remaining_pieces: nil).size, if params.key?(:category)
(contestant.completions.where(remaining_pieces: nil).size == @contest.puzzles.length ? 1 : 0) * contestant.time_seconds, @category = params[:category]
contestant.completions.size > 0 && contestant.completions[-1].remaining_pieces ? contestant.completions[-1].remaining_pieces : 1000000,
contestant.time_seconds
] }
filter_contestants_per_category filter_contestants_per_category
end
if params.key?(:hide_offline) && params[:hide_offline] == "true" if params.key?(:hide_offline) && params[:hide_offline] == "true"
@contestants = @contestants.select { |contestant| !contestant.offline.present? } @contestants = @contestants.select { |contestant| !contestant.offline.present? }
@hide_offline = true
end end
@puzzles = @contest.puzzles.order(:id) if params.key?(:autorefresh)
@action_name = t("helpers.buttons.refresh") @autorefresh = true
if params.key?(:category) end
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}" @puzzles = @contest.puzzles.where(hidden: false).or(@contest.puzzles.where(hidden: nil)).order(:id)
else
@action_path = "/public/#{@contest.friendly_id}" @action_path = "/public/#{@contest.friendly_id}"
end
@space = " " @space = " "
render :scoreboard render :scoreboard
end end
@@ -156,7 +192,7 @@ class ContestsController < ApplicationController
@offline.contest = @contest @offline.contest = @contest
@offline.start_time = Time.now() @offline.start_time = Time.now()
if @offline.save if @offline.save
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}" redirect_to offline_form_edit_path(@contest, @offline)
else else
render :offline_new, status: :unprocessable_entity render :offline_new, status: :unprocessable_entity
end end
@@ -198,6 +234,7 @@ class ContestsController < ApplicationController
contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline) contestant = Contestant.create(contest: @contest, name: @offline.name, offline: @offline)
Completion.create(contest: @contest, Completion.create(contest: @contest,
contestant: contestant, contestant: contestant,
offline: @offline,
puzzle: @contest.puzzles[0], puzzle: @contest.puzzles[0],
completed: @offline.completed, completed: @offline.completed,
display_time_from_start: dp, display_time_from_start: dp,
@@ -205,7 +242,7 @@ class ContestsController < ApplicationController
remaining_pieces: @offline.remaining_pieces) remaining_pieces: @offline.remaining_pieces)
extend_completions!(contestant) extend_completions!(contestant)
end end
redirect_to "/public/#{@contest.friendly_id}/offline/#{@offline.generate_token_for(:token)}/completed" redirect_to offline_form_completed_path(@contest, @offline)
else else
render :offline_edit, status: :unprocessable_entity render :offline_edit, status: :unprocessable_entity
end end
@@ -236,24 +273,32 @@ class ContestsController < ApplicationController
@contest = Contest.find(params[:contest_id]) @contest = Contest.find(params[:contest_id])
end end
def contest_params def new_contest_params
params.expect(contest: [ :lang, :name, :offline_form, :public, :team, :allow_registration ]) params.expect(contest: [ :name, :duration ])
end end
def settings_general_params def settings_general_params
params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ]) params.expect(contest: [ :lang, :name, :duration, :team, :allow_registration ])
end 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 ]) params.expect(contest: [ :offline_form ])
end end
def filter_contestants_per_category def filter_contestants_per_category
if params.key?(:category) && params[:category] != "-1" if @category != "-1"
if params[:category] == "-2" if @category == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 } @contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else 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 end
end end

View File

@@ -79,9 +79,6 @@ class MessagesController < ApplicationController
def convert def convert
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@completion = Completion.new() @completion = Completion.new()
@completion.display_time_from_start = @message.display_time @completion.display_time_from_start = @message.display_time
@completion.completed = true @completion.completed = true
@@ -94,7 +91,7 @@ class MessagesController < ApplicationController
@message = Message.find(params[:id]) @message = Message.find(params[:id])
@message.destroy @message.destroy
redirect_to contest_path(@contest), notice: t("messages.destroy.notice") redirect_to contest_messages_path(@contest), notice: t("messages.destroy.notice")
end end
private private

View File

@@ -11,16 +11,11 @@ class PuzzlesController < ApplicationController
def edit def edit
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize @contest authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@puzzle = Puzzle.new @puzzle = Puzzle.new
end end
@@ -30,10 +25,8 @@ class PuzzlesController < ApplicationController
@puzzle = Puzzle.new(puzzle_params) @puzzle = Puzzle.new(puzzle_params)
@puzzle.contest_id = @contest.id @puzzle.contest_id = @contest.id
if @puzzle.save 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 else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
@@ -42,10 +35,8 @@ class PuzzlesController < ApplicationController
authorize @contest authorize @contest
if @puzzle.update(puzzle_params) 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 else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -54,7 +45,7 @@ class PuzzlesController < ApplicationController
authorize @contest authorize @contest
@puzzle.destroy @puzzle.destroy
redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice") redirect_to contest_puzzles_path(@contest), notice: t("puzzles.destroy.notice")
end end
private private
@@ -68,6 +59,6 @@ class PuzzlesController < ApplicationController
end end
def puzzle_params def puzzle_params
params.expect(puzzle: [ :brand, :name, :image, :pieces ]) params.expect(puzzle: [ :brand, :name, :image, :pieces, :hidden ])
end end
end end

View File

@@ -4,6 +4,7 @@ class SessionsController < ApplicationController
before_action :skip_authorization before_action :skip_authorization
def new def new
@title = "Puzzle scoreboard"
end end
def create def create
@@ -11,6 +12,7 @@ class SessionsController < ApplicationController
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url, notice: t("sessions.new.notice") redirect_to after_authentication_url, notice: t("sessions.new.notice")
else else
@title = "Puzzle scoreboard"
redirect_to new_session_path, alert: "Try another email address or password." redirect_to new_session_path, alert: "Try another email address or password."
end end
end end

View File

@@ -1,20 +1,38 @@
class UsersController < ApplicationController class UsersController < ApplicationController
include CompletionsConcern
before_action :set_user, only: %i[ destroy edit show update ] before_action :set_user, only: %i[ destroy edit show update ]
def index def index
authorize :user authorize :user
@title = t("users.index.title")
@users = User.all @users = User.all
end end
def edit def edit
authorize @user authorize @user
@title = t("users.edit.title")
end end
def update def update
authorize @user 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") redirect_to contests_path, notice: t("users.edit.notice")
else else
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
@@ -48,6 +66,35 @@ class UsersController < ApplicationController
authorize @user authorize @user
end 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 private
def set_user def set_user
@@ -57,4 +104,12 @@ class UsersController < ApplicationController
def user_params def user_params
params.expect(user: [ :username, :email_address, :lang, :password ]) params.expect(user: [ :username, :email_address, :lang, :password ])
end end
def user_general_params
params.expect(user: [ :username, :email_address, :lang ])
end
def user_password_params
params.expect(user: [ :password ])
end
end end

View File

@@ -7,14 +7,15 @@ module ContestsHelper
end end
def display_time(time) def display_time(time)
if time == nil
return ""
end
h = time / 3600 h = time / 3600
m = (time % 3600) / 60 m = (time % 3600) / 60
s = (time % 3600) % 60 s = (time % 3600) % 60
if h > 0 if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s) return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end end
"0:" + pad(s) m.to_s + ":" + pad(s)
end end
end end

3
app/lib/ranking.rb Normal file
View 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

View File

@@ -3,10 +3,12 @@
# Table name: completions # Table name: completions
# #
# id :integer not null, primary key # id :integer not null, primary key
# code :string
# completed :boolean # completed :boolean
# display_relative_time :string # display_relative_time :string
# display_time_from_start :string # display_time_from_start :string
# missing_pieces :integer # missing_pieces :integer
# projected_time :integer
# remaining_pieces :integer # remaining_pieces :integer
# time_seconds :integer # time_seconds :integer
# created_at :datetime not null # created_at :datetime not null
@@ -31,36 +33,34 @@
# puzzle_id (puzzle_id => puzzles.id) # puzzle_id (puzzle_id => puzzles.id)
# #
class Completion < ApplicationRecord class Completion < ApplicationRecord
include ContestsHelper
belongs_to :contest belongs_to :contest
belongs_to :contestant belongs_to :contestant
belongs_to :puzzle belongs_to :puzzle
belongs_to :message, optional: true belongs_to :message, optional: true
before_save :add_time_seconds, if: -> { display_time_from_start.present? } has_one :offline, dependent: :destroy
before_save :nullify_display_time
before_save :clean_pieces
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 :remaining_pieces, presence: true, if: -> { !completed }
validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 } validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 } validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? } validates :remaining_pieces, numericality: { only_integer: true }, if: -> { remaining_pieces.present? }
validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? } validate :remaining_pieces_is_correct, if: -> { remaining_pieces.present? }
validate :contest_code_is_correct, if: -> { code.present? }
def remaining_pieces_is_correct def remaining_pieces_is_correct
if self.remaining_pieces > self.puzzle.pieces if self.remaining_pieces > self.puzzle.pieces
errors.add(:remaining_pieces, "Cannot be greater than the number of pieces for this puzzle") errors.add(:remaining_pieces, I18n.t("activerecord.errors.models.completion.attributes.remaining_pieces.too_large"))
end
end
def nullify_display_time
if self.remaining_pieces
self.display_time_from_start = nil
self.display_relative_time = nil
end end
end end
def add_time_seconds def add_time_seconds
if display_time_from_start.present?
arr = display_time_from_start.split(":") arr = display_time_from_start.split(":")
if arr.size == 3 if arr.size == 3
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
@@ -69,6 +69,10 @@ class Completion < ApplicationRecord
elsif arr.size == 1 elsif arr.size == 1
self.time_seconds = arr[0].to_i self.time_seconds = arr[0].to_i
end end
else
self.time_seconds = self.contest.duration_seconds
self.display_time_from_start = display_time(self.time_seconds)
end
end end
def clean_pieces def clean_pieces
@@ -78,4 +82,22 @@ class Completion < ApplicationRecord
self.missing_pieces = nil self.missing_pieces = nil
end end
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 end

View File

@@ -4,11 +4,18 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# code :string
# duration :string
# duration_seconds :integer
# lang :string default("en") # lang :string default("en")
# name :string # name :string
# offline_form :boolean default(FALSE) # offline_form :boolean default(FALSE)
# pause_time :datetime
# public :boolean default(FALSE) # public :boolean default(FALSE)
# ranking_mode :string
# show_stopwatch :boolean
# slug :string # slug :string
# start_time :datetime
# team :boolean default(FALSE) # team :boolean default(FALSE)
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@@ -36,8 +43,19 @@ class Contest < ApplicationRecord
friendly_id :name, use: :slugged friendly_id :name, use: :slugged
before_save :add_duration_seconds, if: -> { duration.present? }
validates :name, presence: true validates :name, presence: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } } 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 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 end

View File

@@ -6,6 +6,8 @@
# display_time :string # display_time :string
# email :string # email :string
# name :string # name :string
# projected_time :string
# qrcode :string
# time_seconds :integer # time_seconds :integer
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@@ -22,7 +24,7 @@
class Contestant < ApplicationRecord class Contestant < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions, dependent: :destroy has_many :completions, dependent: :destroy
has_one :offline has_one :offline, dependent: :destroy
has_and_belongs_to_many :categories has_and_belongs_to_many :categories
before_validation :initialize_time_seconds_if_empty before_validation :initialize_time_seconds_if_empty
@@ -38,6 +40,19 @@ class Contestant < ApplicationRecord
end end
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 private
def initialize_time_seconds_if_empty def initialize_time_seconds_if_empty

View File

@@ -12,24 +12,28 @@
# submitted :boolean # submitted :boolean
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# completion_id :integer
# contest_id :integer not null # contest_id :integer not null
# contestant_id :integer # contestant_id :integer
# #
# Indexes # Indexes
# #
# index_offlines_on_completion_id (completion_id)
# index_offlines_on_contest_id (contest_id) # index_offlines_on_contest_id (contest_id)
# index_offlines_on_contestant_id (contestant_id) # index_offlines_on_contestant_id (contestant_id)
# #
# Foreign Keys # Foreign Keys
# #
# completion_id (completion_id => completions.id)
# contest_id (contest_id => contests.id) # contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id) # contestant_id (contestant_id => contestants.id)
# #
class Offline < ApplicationRecord class Offline < ApplicationRecord
belongs_to :contest belongs_to :contest
belongs_to :contestant, optional: true belongs_to :contestant, optional: true
belongs_to :completion, optional: true
has_many_attached :images has_many_attached :images, dependent: :destroy
generates_token_for :token generates_token_for :token

View File

@@ -4,6 +4,7 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# brand :string # brand :string
# hidden :boolean
# name :string # name :string
# pieces :integer not null # pieces :integer not null
# created_at :datetime not null # created_at :datetime not null
@@ -22,7 +23,7 @@ class Puzzle < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions, dependent: :destroy has_many :completions, dependent: :destroy
has_one_attached :image has_one_attached :image, dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :pieces, presence: true validates :pieces, presence: true

View File

@@ -6,6 +6,7 @@
# admin :boolean default(FALSE), not null # admin :boolean default(FALSE), not null
# email_address :string not null # email_address :string not null
# lang :string default("en") # lang :string default("en")
# password_change_attempt :boolean
# password_digest :string not null # password_digest :string not null
# username :string # username :string
# created_at :datetime not null # created_at :datetime not null

View File

@@ -35,6 +35,10 @@ class ContestPolicy < ApplicationPolicy
owner_or_admin owner_or_admin
end end
def generate_qrcodes?
owner_or_admin
end
def settings_general_edit? def settings_general_edit?
edit? edit?
end end
@@ -43,11 +47,27 @@ class ContestPolicy < ApplicationPolicy
edit? edit?
end end
def settings_offline_edit? def settings_public_edit?
edit? edit?
end 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? edit?
end end
@@ -55,6 +75,26 @@ class ContestPolicy < ApplicationPolicy
edit? edit?
end 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? def finalize_import?
owner_or_admin owner_or_admin
end end
@@ -75,6 +115,10 @@ class ContestPolicy < ApplicationPolicy
owner_or_admin owner_or_admin
end end
def generate_qrcodes_pdf?
owner_or_admin
end
def upload_csv? def upload_csv?
owner_or_admin owner_or_admin
end end

View File

@@ -20,10 +20,22 @@ class UserPolicy < ApplicationPolicy
end end
def update? def update?
user.admin? || user.id == record.id edit?
end
def change_password?
edit?
end end
def destroy? def destroy?
user.admin? user.admin?
end end
def update_contestants?
user.admin?
end
def regenerate_qrcodes?
user.admin?
end
end end

View File

@@ -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 .row
.col .col
ul.nav.nav-tabs.mb-4 ul.nav.nav-tabs.mb-4
@@ -39,6 +7,12 @@ javascript:
li.nav-item li.nav-item
a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest) a.nav-link class=active_page(contest_puzzles_path(@contest)) href=contest_puzzles_path(@contest)
= t("puzzles.plural").capitalize = 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 li.nav-item
a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest) a.nav-link class=active_page(contest_messages_path(@contest)) href=contest_messages_path(@contest)
= t("messages.plural").capitalize = 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")

View 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")

View File

@@ -1,3 +1,9 @@
- 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| = form_with model: completion, url: url, method: method do |form|
- if @message - if @message
= form.hidden_field :message_id, value: @message.id = form.hidden_field :message_id, value: @message.id
@@ -9,9 +15,14 @@
= @message.author = @message.author
br br
= @message.text = @message.text
.row .row.mb-2
.col .col
h4 = t("completions.singular").capitalize h4
- if @public
= t("completions.form.validate_name", name: @contestant.name)
- else
= t("completions.singular").capitalize
- if @contestants.present?
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
@@ -43,20 +54,22 @@
const missingPiecesEl = document.getElementById('missing_pieces'); const missingPiecesEl = document.getElementById('missing_pieces');
const remainingPiecesEl = document.getElementById('remaining_pieces'); const remainingPiecesEl = document.getElementById('remaining_pieces');
if (e.target.checked) { if (e.target.checked) {
timeEl.style.display = 'block'; timeEl.value = '#{@completion.display_time_from_start}';
missingPiecesEl.style.display = 'block'; missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none'; remainingPiecesEl.style.display = 'none';
} else { } else {
timeEl.style.display = 'none'; timeEl.value = '#{display_time(@contest.duration_seconds)}';
missingPiecesEl.style.display = 'none'; missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block'; remainingPiecesEl.style.display = 'block';
} }
}) })
.row.mb-3 id="time" .row.mb-3
.col .col
.form-floating .form-floating
= form.text_field :display_time_from_start, autocomplete: "off", class: "form-control" = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control", id: "time"
= form.label :display_time_from_start, class: "required" = 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" .row.mb-3 id="missing_pieces"
.col .col
.form-floating .form-floating
@@ -69,18 +82,22 @@
= form.label :remaining_pieces = form.label :remaining_pieces
javascript: javascript:
completedEl = document.getElementById('completion_completed'); completedEl = document.getElementById('completion_completed');
timeEl = document.getElementById('time');
missingPiecesEl = document.getElementById('missing_pieces'); missingPiecesEl = document.getElementById('missing_pieces');
remainingPiecesEl = document.getElementById('remaining_pieces'); remainingPiecesEl = document.getElementById('remaining_pieces');
if (completedEl.checked) { if (completedEl.checked) {
timeEl.style.display = 'block';
missingPiecesEl.style.display = 'block'; missingPiecesEl.style.display = 'block';
remainingPiecesEl.style.display = 'none'; remainingPiecesEl.style.display = 'none';
} else { } else {
timeEl.style.display = 'none';
missingPiecesEl.style.display = 'none'; missingPiecesEl.style.display = 'none';
remainingPiecesEl.style.display = 'block'; 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 .row
.col .col
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"

View File

@@ -1,7 +1,3 @@
.row
.col
h3 Informations
= form_with model: contestant, url: url, method: method do |form| = form_with model: contestant, url: url, method: method do |form|
.row.mb-3 .row.mb-3
.col .col
@@ -15,7 +11,7 @@
= form.label :email = form.label :email
.form-text .form-text
= t("activerecord.attributes.contestant.email_description") = t("activerecord.attributes.contestant.email_description")
- if @contest.categories - if @contest.categories && method == :patch
.row.mt-4 .row.mt-4
.col .col
- @contest.categories.each do |category| - @contest.categories.each do |category|
@@ -33,6 +29,12 @@
.row.mt-5 .row.mt-5
.col .col
h3 Completions h3 Completions
- if @contest.puzzles.length == 0
.row
.col
.alert.alert-warning
= t("contestants.edit.no_puzzles_note")
- else
.row .row
.col .col
.alert.alert-info .alert.alert-info
@@ -40,6 +42,8 @@
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
th scope="col"
= t("activerecord.attributes.completion.completed")
- if @contest.puzzles.size > 1 - if @contest.puzzles.size > 1
th scope="col" th scope="col"
= t("activerecord.attributes.completion.display_time_from_start") = t("activerecord.attributes.completion.display_time_from_start")
@@ -48,6 +52,8 @@
- else - else
th scope="col" th scope="col"
= t("activerecord.attributes.completion.display_time") = t("activerecord.attributes.completion.display_time")
th scope="col"
= t("activerecord.attributes.completion.projected_time")
th scope="col" th scope="col"
= t("activerecord.attributes.completion.missing_pieces") = t("activerecord.attributes.completion.missing_pieces")
th scope="col" th scope="col"
@@ -58,10 +64,19 @@
- @completions.each do |completion| - @completions.each do |completion|
tr scope="row" tr scope="row"
td td
= completion.display_time_from_start - 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 - if @contest.puzzles.size > 1
td td
= completion.display_relative_time = completion.display_relative_time
- else
td
= display_time(completion.projected_time)
td td
= completion.missing_pieces = completion.missing_pieces
td td

View 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")

View 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

View File

@@ -1,5 +1,3 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)" .row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%" .col.d-flex.flex-column style="height: 100%"
.row.mb-4 .row.mb-4
@@ -8,6 +6,8 @@
| + #{t("helpers.buttons.add")} | + #{t("helpers.buttons.add")}
a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px" a.ms-2.btn.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.import")} | #{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" a.ms-2.btn.btn.btn-primary href="/contests/#{@contest.id}/export.csv" style="margin-top: -3px"
| #{t("helpers.buttons.export")} | #{t("helpers.buttons.export")}
@@ -30,7 +30,7 @@
if (option.value == selectedCategory) option.selected = true; if (option.value == selectedCategory) option.selected = true;
}); });
categorySelectEl.addEventListener('change', (e) => { 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" .d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover 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 = 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 td
a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant) a.btn.btn-sm.btn-secondary href=edit_contest_contestant_path(@contest, contestant)
= t("helpers.buttons.open") = t("helpers.buttons.details")

View File

@@ -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" = render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/contestants"

View File

@@ -0,0 +1,2 @@
h4
= "Puzzle validé pour #{@contestant.name} !"

View 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"

View 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"

View 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

View File

@@ -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 - if @contest.categories.size > 0
.row .row
.col .col
@@ -17,29 +5,29 @@ javascript:
option value=-1 option value=-1
= t("contests.scoreboard.all_categories") = t("contests.scoreboard.all_categories")
- @contest.categories.each do |category| - @contest.categories.each do |category|
- if @category == category.id.to_s
option value=category.id selected=true
= category.name
- else
option value=category.id option value=category.id
= category.name = category.name
javascript: javascript:
categorySelectEl = document.getElementById('categories'); document.getElementById('categories').addEventListener('change', (e) => {
urlParams = new URLSearchParams(window.location.search); addParam('category', e.target.value);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
updateParams();
}) })
- if @contest.offline_form && @contest.puzzles.length < 2 - if @contest.offline_form && @contest.puzzles.length < 2
.row .row
.col .col
- if @hide_offline
input type="checkbox" id="offline" style="padding: 5px;" checked=true
- else
input type="checkbox" id="offline" style="padding: 5px;" input type="checkbox" id="offline" style="padding: 5px;"
label for="offline" label for="offline"
.ms-2 .ms-2
= t("contests.scoreboard.hide_offline") = t("contests.scoreboard.hide_offline")
javascript: javascript:
offlineInputEl = document.getElementById('offline'); document.getElementById('offline').addEventListener('change', (e) => {
urlParams = new URLSearchParams(window.location.search); console.log('changed');
offlineInputEl.checked = urlParams.get('hide_offline') == "true"; if (e.target.checked) addParam('hide_offline', e.target.checked);
offlineInputEl.addEventListener('change', (e) => { else removeParam('hide_offline');
updateParams();
}) })

View File

@@ -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")

View 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);

View File

@@ -4,6 +4,12 @@
.form-floating .form-floating
= form.text_field :name, autocomplete: "off", class: "form-control" = form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required" = 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 .row.mt-4
.col .col
= form.submit t("helpers.buttons.create"), class: "btn btn-primary" = form.submit t("helpers.buttons.create"), class: "btn btn-primary"

View File

@@ -21,14 +21,14 @@
.col .col
.form-text.mb-1 .form-text.mb-1
= t("offlines.form.end_image_select") = 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" .form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big") = t("puzzles.form.file_too_big")
javascript: javascript:
function setMaxUploadSize() { function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]'); const el = document.querySelector('input[type="file"]');
el.onchange = function() { 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'; document.getElementById('image-error-message').style.display = 'block';
this.value = ""; this.value = "";
} else { } else {

View File

@@ -8,14 +8,14 @@
.col .col
.form-text.mb-1 .form-text.mb-1
= t("offlines.form.start_image_select") = 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" .form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big") = t("puzzles.form.file_too_big")
javascript: javascript:
function setMaxUploadSize() { function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]'); const el = document.querySelector('input[type="file"]');
el.onchange = function() { 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'; document.getElementById('image-error-message').style.display = 'block';
this.value = ""; this.value = "";
} else { } else {

View File

@@ -1,97 +1,25 @@
css: css:
@media (max-width: 800px) { @media (max-width: 800px) {
a.btn { display: none; } .mobile-single { display: block !important; }
.col-5 { display: none; } .desktop-single { display: none; }
.col-6 { width: 100% !important; display: block !important; } #scoreboard-switches { display: none; }
.small-screen-image { display: block !important; } .stopwatch { font-size: 50px !important; }
.container { margin-top: 2rem !important; }
} }
- if @contest.puzzles.size <= 1 - if @contest.puzzles.size < 2
.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"
= render "selectors" = render "selectors"
.row turbo-frame id="scoreboard"
.mt-3.col-6.d-flex.flex-column style="height: calc(100vh - 250px)" - if @contest.show_stopwatch
.d-flex.flex-column style="overflow-y: auto" .stopwatch id="display-time" style="font-size: 60px; font-weight: 400; margin-bottom: 0; text-align: center;"
table.table.table-striped.table-hover = render "stopwatch_js"
thead a.btn.btn-primary href="" id="refresh-button" style="display: none;"
tr .mobile-single style="display: none;"
th = render "scoreboard_mobile_single"
= t("helpers.rank") .desktop-single
th = render "scoreboard_desktop_single"
= 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"
- else - else
.d-flex.flex-column style="height: calc(100vh - 180px)" turbo-frame id="scoreboard"
.d-flex.flex-row.justify-content-center.mb-5 a.btn.btn-primary href="" id="refresh-button" style="display: none;"
- @contest.puzzles.each do |puzzle| = render "scoreboard_desktop_marathon"
= 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

View File

@@ -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| = form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0 - if @contest.categories.size > 0
@@ -26,6 +31,6 @@
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control" = form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
= form.label :name, class: "required" = form.label :name, class: "required"
= t("activerecord.attributes.category.new") = t("activerecord.attributes.category.new")
.row.mt-3 .row.mt-4
.col .col
= form.submit t("helpers.buttons.add"), class: "btn btn-primary" = form.submit t("helpers.buttons.add"), class: "btn btn-primary"

View File

@@ -1,4 +1,4 @@
= render "settings_nav" = render "params_nav"
= form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form| = form_with model: @contest, url: "/contests/#{@contest.id}/settings/general" do |form|
.row.mt-2.mb-3 .row.mt-2.mb-3
@@ -6,17 +6,24 @@
.form-floating .form-floating
= form.text_field :name, autocomplete: "off", class: "form-control" = form.text_field :name, autocomplete: "off", class: "form-control"
= form.label :name, class: "required" = 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 .row.mb-3
.col .col
.form-floating .form-floating
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select" = form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang = form.label :lang
.row.mt-4.mb-3 .row.mt-2.mb-3
.col .col
.form-check.form-switch .form-floating
= form.check_box :public, class: "form-check-input" = form.text_field :code, autocomplete: "off", class: "form-control"
= form.label :public = form.label :code, class: "required"
.row.mb-3 .form-text = t("activerecord.attributes.contest.code_description")
.row.mb-3 style="display: none"
.col .col
.form-check.form-switch .form-check.form-switch
= form.check_box :team, class: "form-check-input" = form.check_box :team, class: "form-check-input"

View File

@@ -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"

View 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"

View 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"

View 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"

View 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"

View File

@@ -5,7 +5,7 @@ html
body body
.container.mt-5 .container.mt-5
- if @current_user - if @current_user
.float-end style="margin-top: -8px;" .float-end style="margin-top: -5px;"
nav.navbar.bg-body-primary nav.navbar.bg-body-primary
- if @current_user.admin - if @current_user.admin
a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0" a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
@@ -44,9 +44,65 @@ html
= msg = msg
h1.mb-4 h1.mb-4
- 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 = @title
- if @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px" - if @contest && active_page("/contests") == "active" && !@nonav
= @action_name = render "contest_nav"
= yield = yield

View File

@@ -0,0 +1,6 @@
doctype html
html
= render "layouts/header"
body
= yield

View File

@@ -1,10 +1,10 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)" .row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%" .col.d-flex.flex-column style="height: 100%"
.row.mb-4 .row.mb-4
.col .col
.alert.alert-primary
= t("messages.index.info")
- if @messages.length == 0 - if @messages.length == 0
.alert.alert-warning .alert.alert-warning
= t("messages.index.no_messages") = t("messages.index.no_messages")

View File

@@ -2,9 +2,10 @@
.row.mb-3 .row.mb-3
.col.alert.alert-warning .col.alert.alert-warning
= t("puzzles.form.fake_data_recommendation") = t("puzzles.form.fake_data_recommendation")
- if puzzle.id && puzzle.image.attached?
.row.mb-3 .row.mb-3
.col .col
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached? = image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px")
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
@@ -20,6 +21,13 @@
.form-floating .form-floating
= form.number_field :pieces, autocomplete: "off", class: "form-control" = form.number_field :pieces, autocomplete: "off", class: "form-control"
= form.label :pieces, class: "required" = 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 .row.mb-3
.col .col
.form-text.mb-1 .form-text.mb-1
@@ -31,7 +39,7 @@
function setMaxUploadSize() { function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]'); const el = document.querySelector('input[type="file"]');
el.onchange = function() { 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'; document.getElementById('image-error-message').style.display = 'block';
this.value = ""; this.value = "";
} else { } else {
@@ -41,7 +49,7 @@
} }
setMaxUploadSize(); setMaxUploadSize();
.row.mt-4 .row.mt-4.mb-5
.col .col
- if method == :patch - if method == :patch
= link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2" = link_to t("helpers.buttons.delete"), contest_puzzle_path(contest, puzzle), data: { turbo_method: :delete }, class: "btn btn-danger me-2"

View File

@@ -1,5 +1,3 @@
= render "contest_nav"
.row.mb-4 style="height: calc(100vh - 280px)" .row.mb-4 style="height: calc(100vh - 280px)"
.col.d-flex.flex-column style="height: 100%" .col.d-flex.flex-column style="height: 100%"
@@ -7,6 +5,8 @@
.col .col
a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px" a.btn.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")} | + #{t("helpers.buttons.add")}
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
@@ -18,6 +18,8 @@
= t("activerecord.attributes.puzzle.brand") = t("activerecord.attributes.puzzle.brand")
th th
= t("activerecord.attributes.puzzle.pieces") = t("activerecord.attributes.puzzle.pieces")
th
= t("activerecord.attributes.puzzle.hidden")
tbody tbody
- @puzzles.each do |puzzle| - @puzzles.each do |puzzle|
tr.align-middle scope="row" tr.align-middle scope="row"
@@ -29,6 +31,12 @@
= puzzle.brand = puzzle.brand
td td
= puzzle.pieces = 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 td
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle) a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
= t("helpers.buttons.edit") = t("helpers.buttons.edit")

View File

@@ -33,10 +33,10 @@
- if method == :patch - if method == :patch
h4.mt-5 = t("users.edit.password_section") h4.mt-5 = t("users.edit.password_section")
= form_with model: user, method: method do |form| = form_with model: user, url: user_password_path(user) do |form|
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.password_field :password, autocomplete: "off", class: "form-control" = form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required" = form.label :password, class: "required"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary" = form.submit t("helpers.buttons.save_password"), class: "btn btn-primary"

View File

@@ -1,27 +1,39 @@
.row
.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 table.table.table-striped.table-hover
thead thead
tr tr
th scope="col" th scope="col"
| Id | ID
th scope="col" th scope="col"
| Name | Friendly ID
th scope="col" th scope="col"
| Admin? | # Puzzles
th scope="col" th scope="col"
| # contests | # Participants
tbody tbody
- @users.each do |user| - user.contests.each do |contest|
tr scope="row" tr scope="row"
td td
= user.id = contest.id
td td
= user.username = contest.friendly_id
td td
= user.admin ? "Yes" : "No" = contest.puzzles.length
td td
= user.contests.length = contest.contestants.length
td
a.btn.btn-sm.btn-secondary href=contest_path(contest)
= t("helpers.buttons.open")
.row
.col
a.btn.btn-primary href=new_user_path
| New user

73
config/brakeman.ignore Normal file
View 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"
}

View File

@@ -40,6 +40,8 @@ Rails.application.configure do
# Set localhost to be used by links generated in mailer templates. # Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 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. # Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log

View File

@@ -60,6 +60,8 @@ Rails.application.configure do
# Set host to be used by links generated in mailer templates. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" } 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. # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
# config.action_mailer.smtp_settings = { # config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name), # user_name: Rails.application.credentials.dig(:smtp, :user_name),

View File

@@ -47,17 +47,25 @@ en:
contestant: Participant contestant: Participant
display_time: Time display_time: Time
display_time_from_start: Time since start 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 display_relative_time: Time for this puzzle
puzzle: Puzzle puzzle: Puzzle
missing_pieces: Missing pieces missing_pieces: Missing pieces
projected_time: Projected time
remaining_pieces: Remaining pieces (not completed puzzle) remaining_pieces: Remaining pieces (not completed puzzle)
contest: contest:
code: Code for onsite judges
code_description: 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 lang: Language for the public scoreboard
name: Name name: Name
offline_form: Enable the offline participation form 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_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 offline_form_warning: Only for single-puzzle contests
public: Enable the public scoreboard public: Enable the public scoreboard
ranking_mode: Ranking mode (public scoreboard & CSV exports)
show_stopwatch: Display the stopwatch
team: Team contest team: Team contest
team_description: For UI display purposes mainly team_description: For UI display purposes mainly
allow_registration: Allow registration allow_registration: Allow registration
@@ -84,6 +92,8 @@ en:
remaining_pieces: Remaining pieces remaining_pieces: Remaining pieces
puzzle: puzzle:
brand: Brand brand: Brand
hidden: hidden
hidden_description: When hidden, a puzzle doesn't appear in the public scoreboard, nor public forms
image: Image image: Image
name: Name name: Name
pieces: Number of pieces pieces: Number of pieces
@@ -99,6 +109,8 @@ en:
models: models:
completion: completion:
attributes: attributes:
code:
mismatch: "Wrong code"
contestant_id: contestant_id:
taken: "This contestant has already completed the puzzle" taken: "This contestant has already completed the puzzle"
display_time_from_start: display_time_from_start:
@@ -110,8 +122,12 @@ en:
blank: This is required blank: This is required
not_an_integer: This is not an integer not_an_integer: This is not an integer
not_a_number: This is not an integer not_a_number: This is not an integer
too_large: "Cannot be greater than the number of pieces for this puzzle"
contest: contest:
attributes: attributes:
duration:
blank: Must be filled
invalid: Invalid duration
name: name:
blank: The contest name cannot be empty blank: The contest name cannot be empty
contestant: contestant:
@@ -151,6 +167,9 @@ en:
blank: Your email cannot be empty blank: Your email cannot be empty
username: username:
blank: Your username cannot be empty blank: Your username cannot be empty
taken: This username is already taken
password:
blank: Your password cannot be empty
categories: categories:
destroy: destroy:
notice: Category deleted notice: Category deleted
@@ -163,6 +182,10 @@ en:
edit: edit:
notice: Completion updated notice: Completion updated
title: Edit completion title: Edit completion
form:
all_finished: "All puzzles were already completed by %{name}"
code: Judges code
validate_name: "Validate a puzzle for %{name}"
new: new:
notice: Completion added notice: Completion added
title: New completion title: New completion
@@ -174,19 +197,26 @@ en:
notice: Contest updated notice: Contest updated
title: Edit contest settings title: Edit contest settings
form: 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 offline_single_puzzle_warning: This is not available for contests with more than one puzzle
index: index:
title: Welcome %{username}! title: Welcome %{username}!
manage_contests: Manage my contests manage_contests: Manage my contests
new_contest: Create a new contest 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: new:
notice: Contest added notice: Contest added
title: New jigsaw puzzle contest title: New jigsaw puzzle contest
scoreboard: scoreboard:
all_categories: All categories all_categories: All categories
auto_refresh: Auto refresh (30s)
hide_offline: Hide offline participants hide_offline: Hide offline participants
refresh: Activate auto-refresh (every 5s) refresh: Activate auto-refresh (every 5s)
title: "%{name}" title: "%{name}"
@@ -200,6 +230,8 @@ en:
offline_form_disabled: The offline form is disabled offline_form_disabled: The offline form is disabled
public_scoreboard_disabled: The public scoreboard is disabled public_scoreboard_disabled: The public scoreboard is disabled
url_copied: URL copied to the clipboard 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: contestants:
convert_csv: convert_csv:
title: Import participants title: Import participants
@@ -210,12 +242,16 @@ en:
end_image: End image end_image: End image
notice: Participant updated notice: Participant updated
not_finished: Not yet finished not_finished: Not yet finished
no_puzzles_note: No puzzles were added yet
offline_participation: Offline participation offline_participation: Offline participation
start_image: Start image start_image: Start image
title: Participant title: Participant
team_title: Teams team_title: Teams
finalize_import: finalize_import:
title: Import participants title: Import participants
generate_qrcodes:
note: These QR codes allow for judges to fill in results without the need of the organizer's account, for example by printing them and placing them on participant tables
no_code_note: Those can't be used until a code for judges has been set up in the general settings
import: import:
email_column: Participant email email_column: Participant email
import_column: Import? import_column: Import?
@@ -244,23 +280,36 @@ en:
back_to_contestant: "⬅ Back to the participant" back_to_contestant: "⬅ Back to the participant"
confirm: "Confirm" confirm: "Confirm"
create: "Create" create: "Create"
details: Open
delete: "Delete" delete: "Delete"
edit: "Edit" edit: "Edit"
end: Click here to submit your completion end: Click here to submit your completion
export: Export export: Export
generate_qrcodes: Generate QR codes
import: CSV Import import: CSV Import
open: Open open: Open
open_raw: Open in a raw page
refresh: Refresh refresh: Refresh
settings: Settings settings: Settings
sign_in: Sign in sign_in: Sign in
save: Save save: Save
save_password: Save password
start: Click here to start your participation start: Click here to start your participation
stopwatch_continue: Continue
stopwatch_pause: Pause
stopwatch_reset: Reset
stopwatch_start: Start
update: Save modifications update: Save modifications
field: Field field: Field
none: No field selected none: No field selected
rank: Rank 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: messages:
index: index:
info: This section is only used for contests that rely on the connection with Google Meet
no_messages: No messages received yet no_messages: No messages received yet
convert: convert:
title: New completion title: New completion
@@ -291,7 +340,7 @@ en:
title: Edit contest puzzle title: Edit contest puzzle
form: 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. 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 image_select: Select an image
new: new:
notice: Puzzle added notice: Puzzle added
@@ -307,7 +356,7 @@ en:
notice: Settings updated notice: Settings updated
title: "My settings" title: "My settings"
general_section: "General settings" general_section: "General settings"
password_section: "Change password" password_section: "Password"
index: index:
title: "All users" title: "All users"
new: new:

View File

@@ -18,17 +18,25 @@ fr:
contestant_id: Participant.e contestant_id: Participant.e
display_time: Temps display_time: Temps
display_time_from_start: Temps depuis le début 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 display_relative_time: Temps pour ce puzzle
puzzle: Puzzle puzzle: Puzzle
missing_pieces: Pièces manquantes missing_pieces: Pièces manquantes
projected_time: Temps projeté
remaining_pieces: Pièces restantes (puzzle non fini) remaining_pieces: Pièces restantes (puzzle non fini)
contest: contest:
code: Code pour les organisateur.ice.s
code_description: 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 lang: Langue pour le classement public
name: Nom name: Nom
offline_form: Activer le formulaire de participation hors-ligne 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_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 offline_form_warning: Activable uniquement pour les concours avec un seul puzzle
public: Activer le classement public 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: Concours par équipes
team_description: Principalement pour des raisons d'affichage team_description: Principalement pour des raisons d'affichage
allow_registration: Autoriser l'inscription via l'interface allow_registration: Autoriser l'inscription via l'interface
@@ -55,6 +63,8 @@ fr:
remaining_pieces: Pièces restantes remaining_pieces: Pièces restantes
puzzle: puzzle:
brand: Marque 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 image: Image
name: Nom name: Nom
pieces: Nombre de pièces pieces: Nombre de pièces
@@ -70,6 +80,8 @@ fr:
models: models:
completion: completion:
attributes: attributes:
code:
mismatch: "Code non valide"
contestant_id: contestant_id:
taken: "Ce.tte participant.e a déjà complété le puzzle" taken: "Ce.tte participant.e a déjà complété le puzzle"
display_time_from_start: display_time_from_start:
@@ -81,8 +93,12 @@ fr:
blank: Ce champ est obligatoire blank: Ce champ est obligatoire
not_an_integer: Ce n'est pas un nombre entier not_an_integer: Ce n'est pas un nombre entier
not_a_number: Ce n'est pas un nombre entier not_a_number: Ce n'est pas un nombre entier
too_large: "Ne peut pas être plus grand que le nombre de pièces du puzzle"
contest: contest:
attributes: attributes:
duration:
blank: Obligatoire
invalid: Durée invalide
name: name:
blank: Le nom du concours ne peut pas être vide blank: Le nom du concours ne peut pas être vide
contestant: contestant:
@@ -122,6 +138,9 @@ fr:
blank: L'email est obligatoire blank: L'email est obligatoire
username: username:
blank: Le nom d'utilisateur.ice est obligatoire 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: categories:
destroy: destroy:
notice: Catégorie supprimée notice: Catégorie supprimée
@@ -134,6 +153,10 @@ fr:
edit: edit:
notice: Complétion modifiée notice: Complétion modifiée
title: Modifier la complétion 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: new:
notice: Complétion ajoutée notice: Complétion ajoutée
title: Ajout d'une complétion title: Ajout d'une complétion
@@ -145,19 +168,26 @@ fr:
notice: Concours modifié notice: Concours modifié
title: Paramètres du concours title: Paramètres du concours
form: 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 offline_single_puzzle_warning: Ce n'est pas activable pour les concours avec plusieurs puzzles
index: index:
title: Bienvenue %{username} ! title: Bienvenue %{username} !
manage_contests: Mes concours de puzzle manage_contests: Mes concours de puzzle
new_contest: Créer un nouveau concours 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: new:
notice: Concours ajouté notice: Concours ajouté
title: Nouveau concours title: Nouveau concours
scoreboard: scoreboard:
all_categories: Toutes les catégories all_categories: Toutes les catégories
auto_refresh: Auto-rafraichissement (30s)
hide_offline: Cacher les participant.e.s hors-ligne hide_offline: Cacher les participant.e.s hors-ligne
refresh: Activer le rafraichissement automatique de la page (toutes les 5s) refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
title: "%{name}" title: "%{name}"
@@ -171,6 +201,8 @@ fr:
offline_form_disabled: Le formulaire hors-ligne n'est pas activé offline_form_disabled: Le formulaire hors-ligne n'est pas activé
public_scoreboard_disabled: Le classement public n'est pas activé public_scoreboard_disabled: Le classement public n'est pas activé
url_copied: LURL a été copiée dans le presse-papier url_copied: LURL 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: contestants:
convert_csv: convert_csv:
title: Importer des participant.e.s title: Importer des participant.e.s
@@ -181,12 +213,16 @@ fr:
end_image: Image de fin end_image: Image de fin
notice: Participant.e modifié.e notice: Participant.e modifié.e
not_finished: Non terminé not_finished: Non terminé
no_puzzles_note: Aucun puzzle n'a été défini encore pour ce concours
offline_participation: Participation hors-ligne offline_participation: Participation hors-ligne
start_time: Image de début start_image: Image de début
title: Participant.e title: Participant.e
team_title: Équipe team_title: Équipe
finalize_import: finalize_import:
title: Importer des participant.e.s title: Importer des participant.e.s
generate_qrcodes:
note: Ces QR codes permettent, quand imprimés et placés sur les tables des participant.e.s, aux organisateur.ice.s de valider les temps de complétion des puzzles
no_code_note: Les codes ne seront générés qu'une fois un code pour les organisateur.ice.s défini dans les paramètres généraux
import: import:
email_column: Email des participant.e.s email_column: Email des participant.e.s
import_column: Importer ? import_column: Importer ?
@@ -216,22 +252,35 @@ fr:
confirm: "Confirmer" confirm: "Confirmer"
create: "Créer" create: "Créer"
delete: "Supprimer" delete: "Supprimer"
details: Détails
edit: "Modifier" edit: "Modifier"
end: Clique ici pour valider ta complétion du puzzle end: Clique ici pour valider ta complétion du puzzle
export: Exporter export: Exporter
generate_qrcodes: Générer des QR codes
import: Importer un CSV import: Importer un CSV
open: Détails open: Ouvrir
open_raw: Ouvrir dans un format imprimable
refresh: Rafraîchir refresh: Rafraîchir
settings: Paramètres settings: Paramètres
sign_in: Se connecter sign_in: Se connecter
save: Modifier save: Modifier
save_password: Modifier le mot de passe
start: Clique ici pour démarrer ta participation 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 update: Enregistrer les modifications
field: Champ field: Champ
none: Aucun champ sélectionné none: Aucun champ sélectionné
rank: Rang 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: messages:
index: 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 no_messages: Pas de messages reçus pour le moment
convert: convert:
title: Ajout d'une complétion title: Ajout d'une complétion
@@ -262,7 +311,7 @@ fr:
title: Modifier le puzzle title: Modifier le puzzle
form: 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. 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 image_select: Choisis une image
new: new:
notice: Puzzle ajouté notice: Puzzle ajouté
@@ -278,7 +327,7 @@ fr:
notice: Paramètres modifiés notice: Paramètres modifiés
title: "Mes paramètres" title: "Mes paramètres"
general_section: "Paramètres globaux" general_section: "Paramètres globaux"
password_section: "Modifier mon mot de passe" password_section: "Mot de passe"
index: index:
title: "Tous.tes les utilisateur.ices" title: "Tous.tes les utilisateur.ices"
new: new:

View File

@@ -11,9 +11,18 @@ Rails.application.routes.draw do
resources :contests do resources :contests do
get "settings/general", to: "contests#settings_general_edit" get "settings/general", to: "contests#settings_general_edit"
patch "settings/general", to: "contests#settings_general_update" patch "settings/general", to: "contests#settings_general_update"
get "settings/offline", to: "contests#settings_offline_edit" get "settings/public", to: "contests#settings_public_edit"
patch "settings/offline", to: "contests#settings_offline_update" 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 "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 :categories, only: [ :create, :destroy ]
resources :completions resources :completions
resources :contestants resources :contestants
@@ -26,20 +35,50 @@ Rails.application.routes.draw do
get "import/:id", to: "contestants#convert_csv" get "import/:id", to: "contestants#convert_csv"
post "import/:id", to: "contestants#finalize_import" post "import/:id", to: "contestants#finalize_import"
get "export", to: "contestants#export" get "export", to: "contestants#export"
get "generate_qrcodes", to: "contestants#generate_qrcodes"
get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf"
end end
resources :passwords, param: :token resources :passwords, param: :token
resource :session resource :session
resources :users resources :users do
patch "password", to: "users#change_password"
end
options "connect", to: "messages#cors_preflight_check" options "connect", to: "messages#cors_preflight_check"
options "message", to: "messages#cors_preflight_check" options "message", to: "messages#cors_preflight_check"
post "connect", to: "messages#connect" post "connect", to: "messages#connect"
post "message", to: "messages#create" 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", to: "contests#scoreboard"
get "public/:id/offline", to: "contests#offline_new" get "public/:id/offline", to: "contests#offline_new"
post "public/:id/offline", to: "contests#offline_create" post "public/:id/offline", to: "contests#offline_create"
get "public/:id/offline/:token", to: "contests#offline_edit" get "public/:id/offline/:token", to: "contests#offline_edit"
patch "public/:id/offline/:token", to: "contests#offline_update" patch "public/:id/offline/:token", to: "contests#offline_update"
get "public/:id/offline/:token/completed", to: "contests#offline_completed" 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 end

View File

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

View File

@@ -0,0 +1,5 @@
class AddProjectedTimeToCompletion < ActiveRecord::Migration[8.0]
def change
add_column :completions, :projected_time, :integer
end
end

View File

@@ -0,0 +1,5 @@
class AddCompletionToOffline < ActiveRecord::Migration[8.0]
def change
add_reference :offlines, :completion, foreign_key: true
end
end

View File

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

View File

@@ -0,0 +1,5 @@
class AddDurationSecondsToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :duration_seconds, :integer
end
end

View File

@@ -0,0 +1,5 @@
class AddQrcodeToContestant < ActiveRecord::Migration[8.0]
def change
add_column :contestants, :qrcode, :string
end
end

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
class AddHiddenToPuzzle < ActiveRecord::Migration[8.0]
def change
add_column :puzzles, :hidden, :boolean
end
end

View File

@@ -0,0 +1,5 @@
class AddStartTimeToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :start_time, :datetime
end
end

View File

@@ -0,0 +1,5 @@
class AddPauseTimeToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :pause_time, :datetime
end
end

View File

@@ -0,0 +1,5 @@
class AddShowStopwatchToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :show_stopwatch, :boolean
end
end

View File

@@ -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
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do ActiveRecord::Schema[8.0].define(version: 2025_12_10_092658) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -67,6 +67,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.integer "remaining_pieces" t.integer "remaining_pieces"
t.integer "missing_pieces" t.integer "missing_pieces"
t.boolean "completed" t.boolean "completed"
t.integer "projected_time"
t.string "code"
t.index ["contest_id"], name: "index_completions_on_contest_id" t.index ["contest_id"], name: "index_completions_on_contest_id"
t.index ["contestant_id"], name: "index_completions_on_contestant_id" t.index ["contestant_id"], name: "index_completions_on_contestant_id"
t.index ["message_id"], name: "index_completions_on_message_id" t.index ["message_id"], name: "index_completions_on_message_id"
@@ -81,6 +83,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "display_time" t.string "display_time"
t.integer "time_seconds" t.integer "time_seconds"
t.string "projected_time"
t.string "qrcode"
t.index ["contest_id"], name: "index_contestants_on_contest_id" t.index ["contest_id"], name: "index_contestants_on_contest_id"
end end
@@ -95,6 +99,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.string "lang", default: "en" t.string "lang", default: "en"
t.boolean "public", default: false t.boolean "public", default: false
t.boolean "offline_form", 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 ["slug"], name: "index_contests_on_slug", unique: true
t.index ["user_id"], name: "index_contests_on_user_id" t.index ["user_id"], name: "index_contests_on_user_id"
end end
@@ -140,6 +151,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.integer "missing_pieces" t.integer "missing_pieces"
t.integer "remaining_pieces" t.integer "remaining_pieces"
t.boolean "submitted" 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 ["contest_id"], name: "index_offlines_on_contest_id"
t.index ["contestant_id"], name: "index_offlines_on_contestant_id" t.index ["contestant_id"], name: "index_offlines_on_contestant_id"
end end
@@ -151,6 +164,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.string "brand" t.string "brand"
t.integer "pieces", null: false t.integer "pieces", null: false
t.boolean "hidden"
t.index ["contest_id"], name: "index_puzzles_on_contest_id" t.index ["contest_id"], name: "index_puzzles_on_contest_id"
end end
@@ -171,6 +185,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
t.string "username" t.string "username"
t.boolean "admin", default: false, null: false t.boolean "admin", default: false, null: false
t.string "lang", default: "en" t.string "lang", default: "en"
t.boolean "password_change_attempt"
t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["email_address"], name: "index_users_on_email_address", unique: true
end end
@@ -184,6 +199,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_10_110151) do
add_foreign_key "contestants", "contests" add_foreign_key "contestants", "contests"
add_foreign_key "contests", "users" add_foreign_key "contests", "users"
add_foreign_key "messages", "contests" add_foreign_key "messages", "contests"
add_foreign_key "offlines", "completions"
add_foreign_key "offlines", "contestants" add_foreign_key "offlines", "contestants"
add_foreign_key "offlines", "contests" add_foreign_key "offlines", "contests"
add_foreign_key "puzzles", "contests" add_foreign_key "puzzles", "contests"

View 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

View File

@@ -4,11 +4,18 @@
# #
# id :integer not null, primary key # id :integer not null, primary key
# allow_registration :boolean default(FALSE) # allow_registration :boolean default(FALSE)
# code :string
# duration :string
# duration_seconds :integer
# lang :string default("en") # lang :string default("en")
# name :string # name :string
# offline_form :boolean default(FALSE) # offline_form :boolean default(FALSE)
# pause_time :datetime
# public :boolean default(FALSE) # public :boolean default(FALSE)
# ranking_mode :string
# show_stopwatch :boolean
# slug :string # slug :string
# start_time :datetime
# team :boolean default(FALSE) # team :boolean default(FALSE)
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@@ -26,5 +33,11 @@
FactoryBot.define do FactoryBot.define do
factory :contest do factory :contest do
name { Faker::Company.unique.name } name { Faker::Company.unique.name }
duration { "2:00" }
ranking_mode { "actual" }
end
trait :offline do
offline_form { true }
end end
end end

View File

@@ -12,16 +12,19 @@
# submitted :boolean # submitted :boolean
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# completion_id :integer
# contest_id :integer not null # contest_id :integer not null
# contestant_id :integer # contestant_id :integer
# #
# Indexes # Indexes
# #
# index_offlines_on_completion_id (completion_id)
# index_offlines_on_contest_id (contest_id) # index_offlines_on_contest_id (contest_id)
# index_offlines_on_contestant_id (contestant_id) # index_offlines_on_contestant_id (contestant_id)
# #
# Foreign Keys # Foreign Keys
# #
# completion_id (completion_id => completions.id)
# contest_id (contest_id => contests.id) # contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id) # contestant_id (contestant_id => contestants.id)
# #

View File

@@ -6,6 +6,7 @@
# admin :boolean default(FALSE), not null # admin :boolean default(FALSE), not null
# email_address :string not null # email_address :string not null
# lang :string default("en") # lang :string default("en")
# password_change_attempt :boolean
# password_digest :string not null # password_digest :string not null
# username :string # username :string
# created_at :datetime not null # created_at :datetime not null

View File

@@ -20,7 +20,7 @@ RSpec.feature "Contests", type: :feature do
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click 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 end
it "should offer to create a new contest" do 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") click_button I18n.t("helpers.buttons.create")
expect(page).to have_current_path(contest_path(Contest.find_by(name: "Contest name"))) expect(page).to have_current_path("/contests")
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))
end end
end end
end end

View File

@@ -19,7 +19,7 @@ RSpec.feature "Login", type: :feature do
fill_in "Password", with: user.password fill_in "Password", with: user.password
click_button "Sign in" click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title")) expect(page).to have_content("Puzzle scoreboard")
end end
it "should fail to log in the user with an incorrect password" do 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 fill_in "Password", with: Faker::Internet.unique.password
click_button "Sign in" click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title")) expect(page).to have_content("Puzzle scoreboard")
end end
end end
end end

View 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

View File

@@ -21,21 +21,54 @@ RSpec.feature "Users", type: :feature do
expect(page).not_to have_content(I18n.t("users.index.title")) expect(page).not_to have_content(I18n.t("users.index.title"))
end end
it "should be able to create a new contest" do it "should be able to open their account info" do
visit root_path 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 end
it "should be able to open an existing contest" do context "when updating their account info" do
visit root_path let!(:existing_user) { create(:user, username: "taken_username") }
expect(page).to have_content(contest.name) it "should allow changing to an untaken username" do
find("div.card", text: contest.name).find("a").click 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
end end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -6,6 +6,7 @@
# admin :boolean default(FALSE), not null # admin :boolean default(FALSE), not null
# email_address :string not null # email_address :string not null
# lang :string default("en") # lang :string default("en")
# password_change_attempt :boolean
# password_digest :string not null # password_digest :string not null
# username :string # username :string
# created_at :datetime not null # created_at :datetime not null

View File

@@ -67,4 +67,6 @@ RSpec.configure do |config|
config.filter_rails_from_backtrace! config.filter_rails_from_backtrace!
# arbitrary gems may also be filtered via: # arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name") # config.filter_gems_from_backtrace("gem name")
config.include SoManyDevices::DownloadsHelper, type: :system
end end

View 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