Compare commits

..

85 Commits

Author SHA1 Message Date
sto
916c7af738 Fix refresh
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 28s
2025-07-16 10:53:33 +02:00
sto
537f32ab8b Don't change browser history on category filter change
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 31s
2025-07-16 10:43:08 +02:00
sto
5b9862c19c Fix completion deps for puzzle deletion
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 28s
2025-07-16 10:39:59 +02:00
sto
4ca711f5aa Add category selectors on public scoreboards
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 30s
2025-07-16 10:38:21 +02:00
sto
b13ef30807 Permit to modify contestants categories
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 34s
2025-07-16 10:22:47 +02:00
sto
657c5ac47b Internal scoreboard: add category filter
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 34s
2025-07-15 18:23:26 +02:00
sto
502649620b Add contestant categories to contests
All checks were successful
CI / scan_ruby (push) Successful in 1m4s
CI / scan_js (push) Successful in 3m20s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 2m7s
2025-07-14 14:54:55 +02:00
sto
ee476ab81b Improve dashboard display
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-06-27 13:15:53 +02:00
sto
0599def237 Add number of pieces to puzzles
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 34s
2025-06-27 09:23:25 +02:00
sto
b6da55723d Ensure puzzle uniqueness per contestant validation error is shown
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 33s
2025-06-27 08:59:27 +02:00
sto
9862f0c74b Relative times: pad to minutes format
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 39s
2025-06-26 10:56:09 +02:00
sto
1b34d10dee Improve public scoreboard UI + make it responsive
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 43s
2025-06-26 10:53:21 +02:00
sto
d28f888ee2 Add refresh button for the scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 35s
2025-06-25 17:36:33 +02:00
sto
2b1a2c9296 Add "public" setting to contests
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
2025-06-25 10:07:27 +02:00
sto
1a8ea0afee Suggest closest contestant name when converting a message to completion
All checks were successful
CI / scan_ruby (push) Successful in 22s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 39s
2025-06-25 08:54:02 +02:00
sto
2cadc8eca5 Add completion: order contestants by name + add email if present
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 34s
2025-06-25 08:31:42 +02:00
sto
c34b9654c8 Client side puzzle image size validation
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 40s
2025-06-25 08:00:49 +02:00
sto
341e626f6f Delete test directory
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 15s
CI / test (push) Successful in 28s
2025-06-22 07:56:22 +02:00
sto
c22b529858 Add contest feature specs
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) Has been cancelled
2025-06-22 07:55:23 +02:00
sto
50050064c2 Bundle update
All checks were successful
CI / scan_ruby (push) Successful in 35s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 33s
2025-06-22 06:59:51 +02:00
sto
5aa69a108c Allow all origins for sending messages
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 37s
2025-06-21 18:20:27 +02:00
sto
ef3c63ea67 Add /connect route
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 26s
2025-06-21 18:00:06 +02:00
sto
6fb5ba5f3e Flexify scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 27s
2025-06-21 11:11:18 +02:00
sto
6c16e5e232 Improve notice height
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-21 11:04:08 +02:00
sto
2969a24cb0 Fix display_time
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
2025-06-21 10:54:20 +02:00
sto
4b5c09f63b Deactivate badges
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 29s
2025-06-21 10:37:11 +02:00
sto
ca7399f490 Flexify the contest dashboard
Some checks failed
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Has been cancelled
2025-06-21 10:36:19 +02:00
sto
f27b43ef45 Improve top buttons
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 27s
2025-06-21 10:07:42 +02:00
sto
5b908fe37c Add notices
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 39s
2025-06-21 09:59:18 +02:00
sto
2616cbaa71 Add message when the URL is copied to the clipboard
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 26s
2025-06-21 07:23:02 +02:00
sto
70c0fed0c4 Show current puzzle image in puzzle edit form
All checks were successful
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 27s
2025-06-21 07:08:14 +02:00
sto
6c0f5167a4 Add puzzle images to the scoreboard
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-06-21 07:05:12 +02:00
sto
ac3b354480 Contest language & top buttons
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 33s
2025-06-20 08:07:39 +02:00
sto
71f2bb6b70 Fix completion conversion in case of errors
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 30s
2025-06-19 17:28:55 +02:00
sto
ac83a599f3 Make email mandatory + sign in translations
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
2025-06-19 16:54:47 +02:00
sto
67492cdd15 Update completion -> back to contestant
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-19 11:30:21 +02:00
sto
79fb1edfaf Multiples traductions
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 32s
2025-06-19 11:20:33 +02:00
sto
4645b45f5d Fix CSV import & contestant deletion
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 35s
2025-06-19 10:33:36 +02:00
sto
f78a082ad3 Add warning for messages
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 33s
2025-06-18 19:23:44 +02:00
sto
b8674a126f Back buttons
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 37s
2025-06-18 19:09:55 +02:00
sto
67d2ef41b3 Add indicator for processed messages
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 36s
2025-06-18 18:42:04 +02:00
sto
96b8553b1f Add puzzle fake data recommendation
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-06-18 15:00:02 +02:00
sto
194c126c90 Correctly order participants
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-06-18 14:49:16 +02:00
sto
a33f3ff4de Display more participant info on contest dashboard
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 28s
2025-06-18 07:46:45 +02:00
sto
17a1af4e9f Prevent the user from converting messages and warn them, if there are no puzzles
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 33s
2025-06-18 07:40:05 +02:00
sto
baea71b312 Autofill puzzle and don't show it when there's only one puzzle
All checks were successful
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 30s
2025-06-18 07:15:39 +02:00
sto
bc32387c21 Allow message deletion
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 36s
2025-06-18 07:05:39 +02:00
sto
55399d80fe Add CORS to /message
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 36s
2025-06-13 19:33:56 +02:00
sto
d7d90f0c91 Add extension URL display
All checks were successful
CI / scan_ruby (push) Successful in 1m3s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 38s
2025-06-13 18:30:47 +02:00
sto
7444a09046 Translations for the contest dashboard page
All checks were successful
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 28s
2025-05-18 09:52:58 +02:00
sto
ec2201f9a8 Implement CSV import and conversion to contestants
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 26s
2025-05-17 17:40:03 +02:00
sto
939e2157ab Start CSV importer feature
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 32s
2025-05-17 12:03:10 +02:00
sto
5ec0e264ba Upgrade gems
All checks were successful
CI / scan_ruby (push) Successful in 41s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 58s
2025-05-15 08:58:22 +02:00
sto
c4902d85d5 Messages to completions conversion
Some checks failed
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 14s
CI / test (push) Successful in 31s
2025-05-15 08:57:25 +02:00
sto
e65d639ca6 Improve add buttons
Some checks failed
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 31s
2025-05-14 18:09:36 +02:00
sto
1397ddce2f Implement message delete method
Some checks failed
CI / scan_ruby (push) Failing after 13s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 41s
2025-05-14 16:37:28 +02:00
sto
138fe67baa Improve show contest buttons
Some checks failed
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 29s
2025-05-14 15:17:42 +02:00
sto
3a8517e637 Show messages on contest management view
Some checks failed
CI / scan_ruby (push) Failing after 11m23s
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-05-11 21:22:19 +02:00
sto
6afde8a971 Turn puzzles into table
Some checks failed
CI / scan_ruby (push) Failing after 13s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 28s
2025-05-11 21:09:57 +02:00
sto
70005468c6 Add route and controller for incoming messages 2025-05-11 21:09:45 +02:00
sto
2f23938e81 Add message model
Some checks failed
CI / scan_ruby (push) Failing after 14s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 27s
2025-05-11 19:40:30 +02:00
sto
378c3011ef Add prod instructions in README
Some checks failed
CI / scan_ruby (push) Failing after 14s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 27s
2025-05-11 17:19:14 +02:00
sto
a421cd496d Fix SCSS compiled file inclusion in Dockerfile
Some checks failed
CI / scan_ruby (push) Failing after 47s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 26s
2025-05-11 16:19:18 +02:00
sto
21f71f9d32 More translations, incl. attributes
Some checks failed
CI / scan_ruby (push) Failing after 13s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 33s
2025-03-28 14:26:57 +01:00
sto
10fa821f19 Some contest pages translations
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 38s
2025-03-27 14:51:25 +01:00
sto
8b0b1c6745 Add language settings for users, and translate titles to French
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 36s
2025-03-27 12:55:12 +01:00
sto
497768610d Setup I18n for titles
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 26s
2025-03-27 12:15:27 +01:00
sto
26b8064553 Add login & user tests
All checks were successful
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 35s
2025-03-27 10:26:03 +01:00
sto
7023600cd1 Setup Faker and Factorybot
All checks were successful
CI / scan_ruby (push) Successful in 20s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 33s
2025-03-27 09:27:25 +01:00
sto
12f9f33034 Setup Rspec
All checks were successful
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 13s
CI / test (push) Successful in 34s
2025-03-26 19:58:11 +01:00
sto
2144c22bd9 Use the friendly ID gem for contest slugs
All checks were successful
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 10s
CI / lint (push) Successful in 11s
CI / test (push) Successful in 33s
2025-03-26 17:40:56 +01:00
sto
a5d165c4b3 Save display times in the db
All checks were successful
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 41s
2025-03-26 17:00:06 +01:00
sto
c98caeea92 Initialize tests and make them pass
All checks were successful
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 11s
CI / lint (push) Successful in 12s
CI / test (push) Successful in 25s
2025-03-23 13:40:27 +01:00
sto
f8bfb020bc Fully remove .gitea Chrome dep 2025-03-23 13:40:14 +01:00
sto
14be4a32e6 Remove Chrome installation
Some checks failed
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 12s
CI / test (push) Failing after 31s
2025-03-23 13:20:22 +01:00
sto
7ce684ced9 Move completions methods to a concern
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) Has been cancelled
2025-03-23 09:25:32 +01:00
sto
5525cc814a Chrome installation setup for Gitea actions
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) Has been cancelled
2025-03-23 08:57:19 +01:00
sto
2982f44acc Public scoreboard scaffold
Some checks failed
CI / scan_ruby (push) Successful in 14s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 11s
CI / test (push) Failing after 8s
2025-03-23 08:44:38 +01:00
sto
9a2a3a6f33 Add public scoreboard slug & URL
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
2025-03-22 18:21:13 +01:00
sto
d47ebf22ab Fix completion validation
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
2025-03-22 13:08:35 +01:00
sto
6b02eecb9b Add auth in all controllers 2025-03-22 13:07:12 +01:00
sto
5472a400d1 Install Pundit and add UserPolicy
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
2025-03-22 09:48:40 +01:00
sto
0b47cc4d8a User management
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
2025-03-22 09:16:38 +01:00
sto
ce5b729fef Add annotate_rb gem and annotate all models
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
2025-03-22 08:39:40 +01:00
sto
884dbf40d9 Add user admin attribute 2025-03-22 08:16:43 +01:00
130 changed files with 3192 additions and 473 deletions

58
.annotaterb.yml Normal file
View File

@@ -0,0 +1,58 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: false
:exclude_fixtures: false
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: false
:exclude_sti_subclasses: false
:exclude_tests: false
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:active_admin: false
:command:
:debug: false
:hide_default_column_types: ''
:hide_limit_column_types: ''
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
:require: []
:root_dir:
- ''

View File

@@ -56,8 +56,9 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Install packages - name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config google-chrome-stable run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -68,16 +69,12 @@ jobs:
ruby-version: .ruby-version ruby-version: .ruby-version
bundler-cache: true bundler-cache: true
- name: Run tests - name: Setup test database
env: env:
RAILS_ENV: test RAILS_ENV: test
# REDIS_URL: redis://localhost:6379/0 run: bin/rails db:test:prepare
run: bin/rails db:test:prepare test test:system
- name: Keep screenshots from failed system tests - name: Run rspec
uses: actions/upload-artifact@v4 env:
if: failure() RAILS_ENV: test
with: run: bundle exec rspec
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore

View File

@@ -1,12 +0,0 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

1
.rspec Normal file
View File

@@ -0,0 +1 @@
--require spec_helper

View File

@@ -48,9 +48,6 @@ RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY # Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image # Final stage for app image
FROM base FROM base
@@ -58,6 +55,9 @@ FROM base
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails COPY --from=build /rails /rails
# TODO: find how not to depend on this hack to include the compiled SCSS.
RUN cp app/assets/builds/application.css `ls public/assets/application-*.css`
# Run and own only the runtime files as a non-root user for security # Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \ RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \

11
Gemfile
View File

@@ -43,6 +43,9 @@ gem "thruster", require: false
gem "slim" gem "slim"
gem "dartsass-rails" gem "dartsass-rails"
gem "bootstrap", "~> 5.3.3" gem "bootstrap", "~> 5.3.3"
gem "friendly_id", "~> 5.5.0"
gem "csv"
gem "damerau-levenshtein"
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
@@ -53,11 +56,17 @@ group :development, :test do
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false gem "rubocop-rails-omakase", require: false
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
end end
group :development do group :development do
# Use console on exceptions pages [https://github.com/rails/web-console] # Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console" gem "web-console"
gem "annotaterb"
end end
group :test do group :test do
@@ -65,3 +74,5 @@ group :test do
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
end end
gem "pundit", "~> 2.5"

View File

@@ -74,21 +74,21 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2) annotaterb (4.16.0)
autoprefixer-rails (10.4.19.0) activerecord (>= 6.0.0)
execjs (~> 2) activesupport (>= 6.0.0)
base64 (0.2.0) ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
benchmark (0.4.0) benchmark (0.4.1)
bigdecimal (3.1.9) bigdecimal (3.2.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
bootstrap (5.3.3) bootstrap (5.3.5)
autoprefixer-rails (>= 9.1.0)
popper_js (>= 2.11.8, < 3) popper_js (>= 2.11.8, < 3)
brakeman (7.0.0) brakeman (7.0.2)
racc racc
builder (3.3.0) builder (3.3.0)
capybara (3.40.0) capybara (3.40.0)
@@ -101,34 +101,52 @@ GEM
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.0) connection_pool (2.5.3)
crass (1.0.6) crass (1.0.6)
csv (3.3.5)
damerau-levenshtein (1.3.3)
dartsass-rails (0.5.1) dartsass-rails (0.5.1)
railties (>= 6.0.0) railties (>= 6.0.0)
sass-embedded (~> 1.63) sass-embedded (~> 1.63)
date (3.4.1) date (3.4.1)
debug (1.10.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.7) diff-lcs (1.6.2)
drb (2.2.1) dotenv (3.1.8)
ed25519 (1.3.0) drb (2.2.3)
ed25519 (1.4.0)
erb (5.0.1)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
execjs (2.10.0) factory_bot (6.5.4)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.0)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
fugit (1.11.1) fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (4.30.0) google-protobuf (4.31.1)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.30.0-aarch64-linux) google-protobuf (4.31.1-aarch64-linux-gnu)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
google-protobuf (4.30.0-x86_64-linux) google-protobuf (4.31.1-aarch64-linux-musl)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-gnu)
bigdecimal
rake (>= 13)
google-protobuf (4.31.1-x86_64-linux-musl)
bigdecimal bigdecimal
rake (>= 13) rake (>= 13)
i18n (1.14.7) i18n (1.14.7)
@@ -138,29 +156,29 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.0) io-console (0.8.0)
irb (1.15.1) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.13.0) jbuilder (2.13.0)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
json (2.10.2) json (2.12.2)
kamal (2.5.3) kamal (2.7.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2) concurrent-ruby (~> 1.2)
dotenv (~> 3.1) dotenv (~> 3.1)
ed25519 (~> 1.2) ed25519 (~> 1.4)
net-ssh (~> 7.3) net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0) sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3) thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0) zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.4) language_server-protocol (3.17.0.5)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.6.6) logger (1.7.0)
loofah (2.24.0) loofah (2.24.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -169,11 +187,11 @@ GEM
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
matrix (0.4.2) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.4) minitest (5.25.5)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.6) net-imap (0.5.9)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -188,42 +206,45 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.3-aarch64-linux-gnu) nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-aarch64-linux-musl) nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-arm-linux-gnu) nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-arm-linux-musl) nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu) nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-musl) nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
ostruct (0.6.1) ostruct (0.6.2)
parallel (1.26.3) parallel (1.27.0)
parser (3.3.7.1) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
popper_js (2.11.8) popper_js (2.11.8)
pp (0.6.2) pp (0.6.2)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.4.0)
propshaft (1.1.0) propshaft (1.1.0)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
railties (>= 7.0.0) railties (>= 7.0.0)
psych (5.2.3) psych (5.2.6)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.2)
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.12) rack (3.1.16)
rack-session (2.1.0) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
@@ -244,7 +265,7 @@ GEM
activesupport (= 8.0.2) activesupport (= 8.0.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 8.0.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
@@ -260,14 +281,32 @@ GEM
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.3.0)
rdoc (6.12.0) rdoc (6.14.1)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) regexp_parser (2.10.0)
reline (0.6.0) reline (0.6.1)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.1) rexml (3.4.1)
rubocop (1.73.2) rspec-core (3.13.4)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.1)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.4)
rubocop (1.77.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -275,41 +314,42 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.45.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.38.1) rubocop-ast (1.45.1)
parser (>= 3.3.1.0) parser (>= 3.3.7.2)
rubocop-performance (1.24.0) prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3) rubocop-rails (2.32.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0) rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72) rubocop (>= 1.72)
rubocop-performance (>= 1.24) rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30) rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rubyzip (2.4.1) rubyzip (2.4.1)
sass-embedded (1.85.1-aarch64-linux-gnu) sass-embedded (1.89.2-aarch64-linux-gnu)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-aarch64-linux-musl) sass-embedded (1.89.2-aarch64-linux-musl)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-arm-linux-gnueabihf) sass-embedded (1.89.2-arm-linux-gnueabihf)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-arm-linux-musleabihf) sass-embedded (1.89.2-arm-linux-musleabihf)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-x86_64-linux-gnu) sass-embedded (1.89.2-x86_64-linux-gnu)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
sass-embedded (1.85.1-x86_64-linux-musl) sass-embedded (1.89.2-x86_64-linux-musl)
google-protobuf (~> 4.29) google-protobuf (~> 4.31)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.29.1) selenium-webdriver (4.33.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@@ -318,7 +358,7 @@ 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)
solid_cable (3.0.7) solid_cable (3.0.10)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -327,19 +367,19 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.1.3) solid_queue (1.1.5)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0) fugit (~> 1.11.0)
railties (>= 7.1) railties (>= 7.1)
thor (~> 1.3.1) thor (~> 1.3.1)
sqlite3 (2.6.0-aarch64-linux-gnu) sqlite3 (2.7.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl) sqlite3 (2.7.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu) sqlite3 (2.7.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl) sqlite3 (2.7.0-arm-linux-musl)
sqlite3 (2.6.0-x86_64-linux-gnu) sqlite3 (2.7.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl) sqlite3 (2.7.0-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
base64 base64
logger logger
@@ -349,15 +389,15 @@ GEM
ostruct ostruct
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.5) stringio (3.1.7)
temple (0.10.3) temple (0.10.3)
thor (1.3.2) thor (1.3.2)
thruster (0.1.12) thruster (0.1.14)
thruster (0.1.12-aarch64-linux) thruster (0.1.14-aarch64-linux)
thruster (0.1.12-x86_64-linux) thruster (0.1.14-x86_64-linux)
tilt (2.6.0) tilt (2.6.0)
timeout (0.4.3) timeout (0.4.3)
turbo-rails (2.0.13) turbo-rails (2.0.16)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -373,13 +413,13 @@ GEM
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.2) zeitwerk (2.7.3)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -392,19 +432,27 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
annotaterb
bcrypt (~> 3.1.7) bcrypt (~> 3.1.7)
bootsnap bootsnap
bootstrap (~> 5.3.3) bootstrap (~> 5.3.3)
brakeman brakeman
capybara capybara
csv
damerau-levenshtein
dartsass-rails dartsass-rails
debug debug
factory_bot_rails
faker
friendly_id (~> 5.5.0)
importmap-rails importmap-rails
jbuilder jbuilder
kamal kamal
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
pundit (~> 2.5)
rails (~> 8.0.2) rails (~> 8.0.2)
rspec-rails
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver
slim slim

View File

@@ -3,3 +3,44 @@
## Dependencies ## Dependencies
Rails installation guide: https://guides.rubyonrails.org/install_ruby_on_rails.html. Rails installation guide: https://guides.rubyonrails.org/install_ruby_on_rails.html.
## Put in production
### Create a master key
```
bin/rails credentials:edit
```
### Build docker image
```
docker build -t puzzle_scoreboard .
```
### Run docker container
```
sudo docker run -d -p 3000:80 -e RAILS_MASTER_KEY=... -v puzzle-data:/rails/storage --name puzzle_scoreboard puzzle_scoreboard
```
### Access command line
```
sudo docker exec -it puzzle_scoreboard /bin/bash
sudo docker exec -it puzzle_scoreboard /rails/bin/rails console
```
### Logs
Attach to the docker container to see live logs:
```
sudo docker container attach puzzle_scoreboard
```
Or look at the logs saved on the host machine:
```
sudo docker logs puzzle_scoreboard
```

View File

@@ -1,17 +1,40 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication include Authentication
include Pundit::Authorization
before_action :set_title, :set_current_user, :set_lang
after_action :verify_authorized
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern
before_action :set_title, :set_current_user rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
layout "authenticated" layout "authenticated"
private private
def set_title def set_title
@title = "Public scoreboard" 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 end
def set_current_user def set_current_user
@current_user = current_user @current_user = current_user
end end
def set_lang
I18n.locale = @current_user.lang if @current_user
end
def user_not_authorized(exception)
policy_name = exception.policy.class.to_s.underscore
flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
redirect_back_or_to(root_path)
end
def not_found
render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found
end
end end

View File

@@ -0,0 +1,37 @@
class CategoriesController < ApplicationController
before_action :set_contest
before_action :set_category, only: %i[ destroy]
def create
authorize @contest
@category = Category.new(category_params)
@category.contest_id = @contest.id
if @category.save
redirect_to edit_contest_path(@contest), notice: t("categories.new.notice")
else
redirect_to edit_contest_path(@contest), notice: t("categories.new.error")
end
end
def destroy
authorize @contest
@category.destroy
redirect_to edit_contest_path(@contest), notice: t("categories.destroy.notice")
end
private
def set_contest
@contest = Contest.find(params[:contest_id])
end
def set_category
@category = Category.find(params[:id])
end
def category_params
params.expect(category: [ :name ])
end
end

View File

@@ -1,51 +1,87 @@
class CompletionsController < ApplicationController class CompletionsController < ApplicationController
include CompletionsConcern
before_action :set_contest before_action :set_contest
before_action :set_contestant
before_action :set_data, only: %i[ create edit new update ] before_action :set_data, only: %i[ create edit new update ]
before_action :set_completion, only: %i[ destroy edit update ] before_action :set_completion, only: %i[ destroy edit update ]
def edit def edit
@title = "Edit completion" 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
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
if params[:contestant_id] if params[:contestant_id]
@completion.contestant_id = params[:contestant_id] @completion.contestant_id = params[:contestant_id]
end end
@title = "New completion"
end end
def create def create
authorize @contest
@completion = Completion.new(completion_params) @completion = Completion.new(completion_params)
@completion.contest_id = @contest.id @completion.contest_id = @contest.id
if @completion.save if @completion.save
redirect_to contest_path(@contest) extend_completions!(@completion.contestant)
if @contestant && !params[:completion].key?(:message_id)
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.new.notice")
else else
logger = Logger.new(STDOUT) redirect_to @contest, notice: t("completions.new.notice")
logger.info(@completion.errors) end
@title = "New completion" else
if params[:completion].key?(:message_id)
@message = Message.find(params[:completion][:message_id])
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
elsif @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def update def update
if params[:contestant_id] authorize @contest
@completion.contestant_id = params[:contestant_id]
end @completion.contestant_id = params[:contestant_id] if params[:contestant_id]
if @completion.update(completion_params) if @completion.update(completion_params)
redirect_to @contest extend_completions!(@completion.contestant)
if @contestant
redirect_to edit_contest_contestant_path(@contest, @contestant), notice: t("completions.edit.notice")
else else
@title = "Edit completion" redirect_to @contest, notice: t("completions.edit.notice")
end
else
if @contestant
@action_name = t("helpers.buttons.back_to_contestant")
@action_path = edit_contest_contestant_path(@contest, @contestant)
end
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
def destroy def destroy
authorize @contest
@completion.destroy @completion.destroy
if params[:contestant_id] if params[:contestant_id]
redirect_to contest_contestant_path(@contest, params[:contestant_id]) redirect_to contest_contestant_path(@contest, params[:contestant_id]), notice: t("completions.destroy.notice")
else else
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("completions.destroy.notice")
end end
end end
@@ -55,8 +91,16 @@ class CompletionsController < ApplicationController
@contest = Contest.find(params[:contest_id]) @contest = Contest.find(params[:contest_id])
end end
def set_contestant
if params.key?(:contestant_id)
@contestant = Contestant.find(params[:contestant_id])
elsif params[:completion].key?(:contestant_id)
@contestant = Contestant.find(params[:completion][:contestant_id])
end
end
def set_data def set_data
@contestants = @contest.contestants @contestants = @contest.contestants.order(:name)
@puzzles = @contest.puzzles @puzzles = @contest.puzzles
end end
@@ -65,6 +109,6 @@ class CompletionsController < ApplicationController
end end
def completion_params def completion_params
params.expect(completion: [ :time_seconds, :contestant_id, :puzzle_id ]) params.expect(completion: [ :display_time_from_start, :contestant_id, :message_id, :puzzle_id ])
end end
end end

View File

@@ -51,6 +51,7 @@ module Authentication
end end
def current_user def current_user
return unless Current.session
return unless Current.session[:user_id] return unless Current.session[:user_id]
User.find(Current.session[:user_id]) User.find(Current.session[:user_id])
end end

View File

@@ -0,0 +1,32 @@
module CompletionsConcern
extend ActiveSupport::Concern
def pad(n)
if n > 9
return n.to_s
end
"0" + n.to_s
end
def display_time(time)
h = time / 3600
m = (time % 3600) / 60
s = (time % 3600) % 60
if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end
s.to_s
end
def extend_completions!(contestant)
current_time_from_start = 0
contestant.completions.order(:time_seconds).each do |completion|
completion.update(display_time_from_start: display_time(completion.time_seconds),
display_relative_time: display_time(completion.time_seconds - current_time_from_start))
current_time_from_start = completion.time_seconds
end
contestant.update(display_time: display_time(current_time_from_start), time_seconds: current_time_from_start)
end
end

View File

@@ -4,37 +4,111 @@ class ContestantsController < ApplicationController
before_action :set_completions, only: %i[edit update ] before_action :set_completions, only: %i[edit update ]
def edit def edit
@title = "Contestant" authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@contestant = Contestant.new @contestant = Contestant.new
@title = "New contestant"
end end
def create def create
authorize @contest
@contestant = Contestant.new(contestant_params) @contestant = Contestant.new(contestant_params)
@contestant.contest_id = @contest.id @contestant.contest_id = @contest.id
if @contestant.save if @contestant.save
redirect_to contest_path(@contest) update_contestant_categories
redirect_to contest_path(@contest), notice: t("contestants.new.notice")
else else
@title = "New contestant" @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
def update def update
authorize @contest
if @contestant.update(contestant_params) if @contestant.update(contestant_params)
redirect_to @contest update_contestant_categories
redirect_to @contest, notice: t("contestants.edit.notice")
else else
@title = "Contestant" @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
def destroy def destroy
authorize @contest
@contestant.destroy @contestant.destroy
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("contestants.destroy.notice")
end
def import
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.new
end
def upload_csv
authorize @contest
@csv_import = CsvImport.new(params.require(:csv_import).permit(:file, :separator))
if @csv_import.save
redirect_to "/contests/#{@contest.id}/import/#{@csv_import.id}"
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :import, status: :unprocessable_entity
end
end
def convert_csv
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
@form = Forms::CsvConversionForm.new
end
def finalize_import
authorize @contest
@csv_import = CsvImport.find(params[:id])
@content = JSON.parse(@csv_import.content)
all_params = params.require(:forms_csv_conversion_form)
@form = Forms::CsvConversionForm.new(params.require(:forms_csv_conversion_form).permit(:email_column, :name_column))
if @form.valid?
@content.each_with_index do |row, i|
if all_params["row_#{i}".to_sym] == "1"
if @form.email_column == -1
Contestant.create(name: row[@form.name_column], contest: @contest)
else
logger.info("Email")
Contestant.create(name: row[@form.name_column], email: row[@form.email_column], contest: @contest)
end
end
end
redirect_to contest_path(@contest), notice: t("contestants.import.notice")
else
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
render :convert_csv, status: :unprocessable_entity
end
end end
private private
@@ -47,35 +121,22 @@ class ContestantsController < ApplicationController
@contestant = Contestant.find(params[:id]) @contestant = Contestant.find(params[:id])
end end
def pad(n)
if n > 9
return n.to_s
end
"0" + n.to_s
end
def display_time(seconds)
if seconds > 3600
hours = seconds / 3600
return hours.to_s + ":" + display_time(seconds % 3600)
elsif seconds > 60
minutes = seconds / 60
return pad(minutes) + ":" + display_time(seconds % 60)
end
pad(seconds)
end
def set_completions def set_completions
@completions = @contestant.completions.order(:time_seconds) @completions = @contestant.completions.order(:time_seconds)
current_time_from_start = 0
@completions.each do |completion|
completion.display_time_from_start = display_time(completion.time_seconds)
completion.display_relative_time = display_time(completion.time_seconds - current_time_from_start)
current_time_from_start += completion.time_seconds
end
end end
def contestant_params def contestant_params
params.expect(contestant: [ :email, :name ]) params.expect(contestant: [ :email, :name ])
end end
def update_contestant_categories
@contestant.categories.clear
@contest.categories.each do |category|
logger.info(params[:contestant]["category_#{category.id}".to_sym] == "1")
if params[:contestant].key?("category_#{category.id}".to_sym) && params[:contestant]["category_#{category.id}".to_sym] == "1"
@contestant.categories << category
end
end
@contestant.save
end
end end

View File

@@ -1,54 +1,100 @@
class ContestsController < ApplicationController class ContestsController < ApplicationController
before_action :set_contest, only: %i[ destroy edit show update ] before_action :set_contest, only: %i[ destroy edit show update ]
skip_before_action :require_authentication, only: %i[ scoreboard ]
def index def index
authorize :contest
@contests = current_user.contests @contests = current_user.contests
@title = "Welcome #{current_user.username}!" @title = I18n.t("contests.index.title", username: current_user.username)
end end
def show def show
@title = @contest.name authorize @contest
@contestants = @contest.contestants.order(:name)
@title = I18n.t("contests.show.title", name: @contest.name)
@action_name = t("helpers.buttons.edit")
@action_path = edit_contest_path(@contest)
@contestants = @contest.contestants.sort_by { |contestant| [ -contestant.completions.size, contestant.time_seconds ] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id) @puzzles = @contest.puzzles.order(:id)
@messages = @contest.messages.order(:time_seconds)
set_badges set_badges
end end
def edit def edit
@title = "Edit contest settings" authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize :contest
@contest = Contest.new @contest = Contest.new
@title = "New jigsaw puzzle competition"
end end
def create def create
authorize :contest
@contest = Contest.new(contest_params) @contest = Contest.new(contest_params)
@contest.user_id = current_user.id @contest.user_id = current_user.id
if @contest.save if @contest.save
redirect_to @contest redirect_to @contest, notice: t("contests.new.notice")
else else
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def update def update
authorize @contest
if @contest.update(contest_params) if @contest.update(contest_params)
redirect_to @contest redirect_to @contest, notice: t("contests.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
def destroy def destroy
authorize @contest
@contest.destroy
redirect_to contests_path, notice: t("contests.destroy.notice")
end
def scoreboard
@contest = Contest.find_by(slug: params[:id])
unless @contest && @contest.public
skip_authorization
not_found and return
end
authorize @contest
I18n.locale = @contest.lang
@title = I18n.t("contests.scoreboard.title", name: @contest.name)
@contestants = @contest.contestants.sort_by { |contestant| [ -contestant.completions.size, contestant.time_seconds ] }
filter_contestants_per_category
@puzzles = @contest.puzzles.order(:id)
@action_name = t("helpers.buttons.refresh")
if params.key?(:category)
@action_path = "/public/#{@contest.friendly_id}?category=#{params[:category]}"
else
@action_path = "/public/#{@contest.friendly_id}"
end
render :scoreboard
end end
private private
def set_badges def set_badges
@badges = [] @badges = []
@badges.push("team") if @contest.team @badges.push(t("helpers.badges.team")) if @contest.team
@badges.push("registration") if @contest.allow_registration @badges.push(t("helpers.badges.registration")) if @contest.allow_registration
end end
def set_contest def set_contest
@@ -56,6 +102,16 @@ class ContestsController < ApplicationController
end end
def contest_params def contest_params
params.expect(contest: [ :name, :team, :allow_registration ]) params.expect(contest: [ :lang, :name, :public, :team, :allow_registration ])
end
def filter_contestants_per_category
if params.key?(:category) && params[:category] != "-1"
if params[:category] == "-2"
@contestants = @contestants.select { |contestant| contestant.categories.size == 0 }
else
@contestants = @contestants.select { |contestant| contestant.categories.where(id: params[:category]).any? }
end
end
end end
end end

View File

@@ -0,0 +1,113 @@
class MessagesController < ApplicationController
include CompletionsConcern
skip_before_action :verify_authenticity_token, only: %i[ create connect cors_preflight_check ]
skip_before_action :require_authentication, only: %i[ create connect cors_preflight_check ]
before_action :cors_set_access_control_headers, only: %i[ create connect cors_preflight_check ]
before_action :set_contest, only: %i[ convert destroy ]
before_action :set_data, only: %i[ convert ]
def self.local_prefixes
super + [ "completions" ]
end
def cors_set_access_control_headers
response.set_header("Access-Control-Allow-Origin", "*")
response.set_header("Access-Control-Allow-Credentials", "true")
response.set_header("Access-Control-Allow-Methods", "POST")
response.set_header("Access-Control-Allow-Headers", "*")
response.set_header("Access-Control-Max-Age", "86400")
end
def cors_preflight_check
skip_authorization
end
def connect
skip_authorization
if !params.key?(:token)
respond_to do |format|
format.json { render json: { error: "no token provided" }, status: 400 }
end
else
@contest = Contest.find_by_token_for(:token, params[:token])
if @contest
respond_to do |format|
format.json { render json: { name: @contest.name }, status: 200 }
end
else
respond_to do |format|
format.json { render json: { error: "invalid token" }, status: 400 }
end
end
end
end
def create
skip_authorization
begin
@contest = Contest.find_by_token_for(:token, params[:token])
@message = Message.new(text: params[:text], author: params[:author], time_seconds: params[:time_seconds],
display_time: display_time(params[:time_seconds]), contest: @contest)
if @contest && @message.save
respond_to do |format|
format.json { render json: {}, status: 200 }
end
else
respond_to do |format|
format.json { render json: { error: "invalid token" }, status: 400 }
end
end
rescue
respond_to do |format|
format.json { render json: { error: "invalid token" }, status: 400 }
end
end
end
def convert
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@completion = Completion.new()
@completion.display_time_from_start = @message.display_time
render "completions/new"
end
def destroy
authorize @contest
@message = Message.find(params[:id])
@message.destroy
redirect_to contest_path(@contest), notice: t("messages.destroy.notice")
end
private
def set_contest
@contest = Contest.find(params[:contest_id])
end
def set_data
@message = Message.find(params[:message_id])
@puzzles = @contest.puzzles
@contestants = @contest.contestants.order(:name)
if @contestants.size > 0
@closest_contestant = @contestants.first
closest_distance = 10000
@contestants.each do |contestant|
distance = DamerauLevenshtein.distance(@message.author, contestant.name)
if distance < closest_distance
closest_distance = distance
@closest_contestant = contestant
end
end
end
end
end

View File

@@ -3,37 +3,51 @@ class PuzzlesController < ApplicationController
before_action :set_puzzle, only: %i[ destroy edit update] before_action :set_puzzle, only: %i[ destroy edit update]
def edit def edit
@title = "Edit contest puzzle" authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
end end
def new def new
authorize @contest
@action_name = t("helpers.buttons.back")
@action_path = contest_path(@contest)
@puzzle = Puzzle.new @puzzle = Puzzle.new
@title = "New contest puzzle"
end end
def create def create
authorize @contest
@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) redirect_to contest_path(@contest), notice: t("puzzles.new.notice")
else else
@title = "New contest puzzle" @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
def update def update
authorize @contest
if @puzzle.update(puzzle_params) if @puzzle.update(puzzle_params)
redirect_to @contest redirect_to @contest, notice: t("puzzles.edit.notice")
else else
@title = "Edit contest puzzle" @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
def destroy def destroy
authorize @contest
@puzzle.destroy @puzzle.destroy
redirect_to contest_path(@contest) redirect_to contest_path(@contest), notice: t("puzzles.destroy.notice")
end end
private private
@@ -47,6 +61,6 @@ class PuzzlesController < ApplicationController
end end
def puzzle_params def puzzle_params
params.expect(puzzle: [ :brand, :name, :image ]) params.expect(puzzle: [ :brand, :name, :image, :pieces ])
end end
end end

View File

@@ -1,6 +1,7 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ] allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." } rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
before_action :skip_authorization
def new def new
end end
@@ -8,7 +9,7 @@ class SessionsController < ApplicationController
def create def create
if user = User.authenticate_by(params.permit(:email_address, :password)) if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url redirect_to after_authentication_url, notice: t("sessions.new.notice")
else else
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

View File

@@ -2,29 +2,50 @@ class UsersController < ApplicationController
before_action :set_user, only: %i[ destroy edit show update ] before_action :set_user, only: %i[ destroy edit show update ]
def index def index
@title = "All users" authorize :user
@users = User.all
end end
def edit def edit
@title = "My settings" authorize @user
end end
def update def update
authorize @user
if @user.update(user_params) if @user.update(user_params)
redirect_to @user redirect_to contests_path, notice: t("users.edit.notice")
else else
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
def show def show
authorize @user
redirect_to edit_user_path(@user) redirect_to edit_user_path(@user)
end end
def new def new
authorize :user
@user = User.new()
end
def create
authorize :user
@user = User.new(user_params)
if @user.save
redirect_to users_path, notice: t("users.new.notice")
else
render :new, status: :unprocessable_entity
end
end end
def destroy def destroy
authorize @user
end end
private private
@@ -34,6 +55,6 @@ class UsersController < ApplicationController
end end
def user_params def user_params
params.expect(user: [ :username, :email_address ]) params.expect(user: [ :username, :email_address, :lang, :password ])
end end
end end

View File

@@ -1,2 +1,20 @@
module ContestsHelper module ContestsHelper
def pad(n)
if n > 9
return n.to_s
end
"0" + n.to_s
end
def display_time(time)
h = time / 3600
m = (time % 3600) / 60
s = (time % 3600) % 60
if h > 0
return h.to_s + ":" + pad(m) + ":" + pad(s)
elsif m > 0
return m.to_s + ":" + pad(s)
end
"0:" + pad(s)
end
end end

14
app/lib/forms.rb Normal file
View File

@@ -0,0 +1,14 @@
module Forms
class CsvConversionForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations::Callbacks
include ActiveRecord::Transactions
attribute :name_column, :integer
attribute :email_column, :integer
validates :name_column, presence: true
validates_numericality_of :name_column, greater_than: -1
end
end

3
app/lib/languages.rb Normal file
View File

@@ -0,0 +1,3 @@
module Languages
AVAILABLE_LANGUAGES = [ { id: "en", name: "English" }, { id: "fr", name: "Français" } ]
end

24
app/models/category.rb Normal file
View File

@@ -0,0 +1,24 @@
# == 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)
#
class Category < ApplicationRecord
belongs_to :contest
has_and_belongs_to_many :contestants
validates :name, presence: true
end

View File

@@ -1,11 +1,52 @@
# == Schema Information
#
# Table name: completions
#
# id :integer not null, primary key
# display_relative_time :string
# display_time_from_start :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
# contestant_id :integer not null
# message_id :integer
# puzzle_id :integer not null
#
# Indexes
#
# index_completions_on_contest_id (contest_id)
# index_completions_on_contestant_id (contestant_id)
# index_completions_on_message_id (message_id)
# index_completions_on_puzzle_id (puzzle_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
# contestant_id (contestant_id => contestants.id)
# message_id (message_id => messages.id)
# puzzle_id (puzzle_id => puzzles.id)
#
class Completion < ApplicationRecord class Completion < ApplicationRecord
belongs_to :contest belongs_to :contest
belongs_to :contestant belongs_to :contestant
belongs_to :puzzle belongs_to :puzzle
belongs_to :message, optional: true
attr_accessor :display_time_from_start, :display_relative_time before_save :add_time_seconds
validates :time_seconds, presence: true validates :display_time_from_start, presence: true, format: { with: /\A(((\d\d|\d):\d\d|\d\d|\d):\d\d|\d\d|\d)\z/ }
validates_numericality_of :time_seconds validates :contestant_id, uniqueness: { scope: :puzzle }, if: -> { contest.puzzles.size == 1 }
validates :puzzle_id, uniqueness: { score: :contestant } validates :puzzle_id, uniqueness: { scope: :contestant }, if: -> { contest.puzzles.size > 1 }
def add_time_seconds
arr = display_time_from_start.split(":")
if arr.size == 3
self.time_seconds = arr[0].to_i * 3600 + arr[1].to_i * 60 + arr[2].to_i
elsif arr.size == 2
self.time_seconds = arr[0].to_i * 60 + arr[1].to_i
elsif arr.size == 1
self.time_seconds = arr[0].to_i
end
end
end end

View File

@@ -1,7 +1,41 @@
# == Schema Information
#
# Table name: contests
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string
# public :boolean default(FALSE)
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
class Contest < ApplicationRecord class Contest < ApplicationRecord
belongs_to :user extend FriendlyId
belongs_to :user
has_many :categories
has_many :completions, dependent: :destroy has_many :completions, dependent: :destroy
has_many :contestants, dependent: :destroy has_many :contestants, dependent: :destroy
has_many :puzzles, dependent: :destroy has_many :puzzles, dependent: :destroy
has_many :messages, dependent: :destroy
friendly_id :name, use: :slugged
validates :name, presence: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
generates_token_for :token
end end

View File

@@ -1,7 +1,47 @@
# == Schema Information
#
# Table name: contestants
#
# id :integer not null, primary key
# display_time :string
# email :string
# name :string
# time_seconds :integer
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_contestants_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
class Contestant < ApplicationRecord class Contestant < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions, dependent: :destroy
has_and_belongs_to_many :categories
has_many :completions before_validation :initialize_time_seconds_if_empty
validates :name, presence: true validates :name, presence: true
validates :time_seconds, presence: true
def form_name
if email.present?
"#{name} - #{email}"
else
name
end
end
private
def initialize_time_seconds_if_empty
if !self.time_seconds
self.time_seconds = 0
end
end
end end

51
app/models/csv_import.rb Normal file
View File

@@ -0,0 +1,51 @@
# == 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
#
class CsvImport < ApplicationRecord
enum :separator, { comma: ",", semicolon: ";" }, suffix: true, default: :comma
has_one_attached :file
validates :file, presence: true
validate :acceptable_csv, on: :create
before_save :read_csv
def acceptable_csv
return unless file.attached?
if file.blob.byte_size > 5 * 1024 * 1024
errors.add(:file, "this csv file is too large, it must be under 5MB")
return
end
if file.content_type != "text/csv"
errors.add(:file, :not_a_csv_file)
return
end
begin
csv = CSV.read(attachment_changes["file"].attachable.path, col_sep: separator_for_database)
errors.add(:file, :empty) if csv.count < 1 || (csv.count == 1 && csv[0].count == 1 && csv[0][0] == "")
rescue CSV::MalformedCSVError => e
errors.add(:file, e.message)
end
end
def read_csv
self.content = JSON.dump(CSV.read(attachment_changes["file"].attachable.path, col_sep: separator_for_database))
end
def options_for_separator
keys = self.class.separators.keys
keys.map(&:humanize).zip(keys).to_h
end
end

28
app/models/message.rb Normal file
View File

@@ -0,0 +1,28 @@
# == 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)
#
class Message < ApplicationRecord
belongs_to :contest
has_many :completions, dependent: :nullify
validates :author, presence: true
validates :text, presence: true
end

View File

@@ -1,9 +1,29 @@
# == Schema Information
#
# Table name: puzzles
#
# id :integer not null, primary key
# brand :string
# name :string
# pieces :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# contest_id :integer not null
#
# Indexes
#
# index_puzzles_on_contest_id (contest_id)
#
# Foreign Keys
#
# contest_id (contest_id => contests.id)
#
class Puzzle < ApplicationRecord class Puzzle < ApplicationRecord
belongs_to :contest belongs_to :contest
has_many :completions has_many :completions, dependent: :destroy
has_one_attached :image has_one_attached :image
validates :name, presence: true validates :name, presence: true
validates :brand, presence: true validates :pieces, presence: true
end end

View File

@@ -1,3 +1,22 @@
# == Schema Information
#
# Table name: sessions
#
# id :integer not null, primary key
# ip_address :string
# user_agent :string
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_sessions_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
class Session < ApplicationRecord class Session < ApplicationRecord
belongs_to :user belongs_to :user
end end

View File

@@ -1,3 +1,20 @@
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_digest :string not null
# username :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email_address (email_address) UNIQUE
#
class User < ApplicationRecord class User < ApplicationRecord
has_many :contests, dependent: :destroy has_many :contests, dependent: :destroy
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
@@ -6,4 +23,6 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(e) { e.strip.downcase } normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :username, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true
validates :email_address, presence: true, uniqueness: true
validates :lang, inclusion: { in: Languages::AVAILABLE_LANGUAGES.map { |lang| lang[:id] } }
end end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NoMethodError, "You must define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
end
end

View File

@@ -0,0 +1,9 @@
class CompletionPolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -0,0 +1,53 @@
class ContestPolicy < ApplicationPolicy
def index?
true
end
def show?
record.user.id == user.id || user.admin?
end
def new?
true
end
def create?
true
end
def convert?
record.user.id == user.id || user.admin?
end
def convert_csv?
record.user.id == user.id || user.admin?
end
def edit?
record.user.id == user.id || user.admin?
end
def finalize_import?
record.user.id == user.id || user.admin?
end
def update?
record.user.id == user.id || user.admin?
end
def destroy?
record.user.id == user.id || user.admin?
end
def import?
record.user.id == user.id || user.admin?
end
def scoreboard?
true
end
def upload_csv?
record.user.id == user.id || user.admin?
end
end

View File

@@ -0,0 +1,9 @@
class ContestantPolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -0,0 +1,9 @@
class PuzzlePolicy < ContestPolicy
def index?
false
end
def show?
false
end
end

View File

@@ -0,0 +1,29 @@
class UserPolicy < ApplicationPolicy
def index?
user.admin?
end
def show?
user.admin? || user.id == record.id
end
def new?
user.admin?
end
def create?
user.admin?
end
def edit?
user.admin? || user.id == record.id
end
def update?
user.admin? || user.id == record.id
end
def destroy?
user.admin?
end
end

View File

@@ -1,19 +1,41 @@
= form_with model: completion, url: url, method: method do |form| = form_with model: completion, url: url, method: method do |form|
- if @message
= form.hidden_field :message_id, value: @message.id
.row.mb-3
.col
h4 = t("messages.singular").capitalize
.alert.alert-secondary
b
= @message.author
br
= @message.text
.row
.col
h4 = t("completions.singular").capitalize
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.text_field :time_seconds, autocomplete: "off", class: "form-control" = form.text_field :display_time_from_start, autocomplete: "off", class: "form-control"
= form.label :time_seconds, class: "required" = form.label :display_time_from_start, class: "required"
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.select :contestant_id, @contestants.map { |contestant| [contestant.name, contestant.id] }, {}, class: "form-select" = form.select :contestant_id, @contestants.map { |contestant| [contestant.form_name, contestant.id] }, {}, class: "form-select"
= form.label :contestant_id = form.label :contestant_id
- if @closest_contestant
javascript:
el = document.querySelector('select[name="completion[contestant_id]"]');
el.value = "#{@closest_contestant.id}"
- if @puzzles.size > 1
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select" = form.select :puzzle_id, @puzzles.map { |puzzle| ["#{puzzle.name} - #{puzzle.brand}", puzzle.id] }, {}, class: "form-select"
= form.label :puzzle_id = form.label :puzzle_id
- elsif @puzzles.size == 1
= form.hidden_field :puzzle_id, value: @puzzles.first.id
- else
= form.hidden_field :puzzle_id
.row .row
.col .col
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"

View File

@@ -1 +1 @@
= render "form", contest: @contest, completion: @completion, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/completions/#{@completion.id}" = render "form", contest: @contest, completion: @completion, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/completions/#{@completion.id}"

View File

@@ -1 +1 @@
= render "form", completion: @completion, submit_text: "Create", method: :post, url: "/contests/#{@contest.id}/completions" = render "form", completion: @completion, submit_text: t("helpers.buttons.create"), method: :post, url: "/contests/#{@contest.id}/completions"

View File

@@ -13,11 +13,20 @@
.form-floating .form-floating
= form.text_field :email, autocomplete: "off", class: "form-control" = form.text_field :email, autocomplete: "off", class: "form-control"
= form.label :email = form.label :email
.form-text Optional. Fill this only if you intend to send emails through this app. .form-text
= t("activerecord.attributes.contestant.email_description")
- if @contest.categories
.row.mt-4
.col
- @contest.categories.each do |category|
.form-check.form-switch
= form.check_box "category_#{category.id}".to_sym, class: "form-check-input", checked: @contestant.categories.where(id: category.id).any?
= form.label category.name
.row.mt-4 .row.mt-4
.col .col
- if method == :patch - if method == :patch
= link_to "Delete", contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2" = link_to t("helpers.buttons.delete"), contest_contestant_path(contest, contestant), data: { turbo_method: :delete }, class: "btn btn-danger me-2"
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"
- if method == :patch - if method == :patch
@@ -27,27 +36,35 @@
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
- if @contest.puzzles.size > 1
th scope="col" th scope="col"
| Time since start = t("activerecord.attributes.completion.display_time_from_start")
th scope="col" th scope="col"
| Relative time = t("activerecord.attributes.completion.display_relative_time")
- else
th scope="col" th scope="col"
| Puzzle = t("activerecord.attributes.completion.display_time")
th scope="col"
= t("activerecord.attributes.completion.puzzle")
tbody tbody
- @completions.each do |completion| - @completions.each do |completion|
tr scope="row" tr scope="row"
td td
= completion.display_time_from_start = completion.display_time_from_start
- if @contest.puzzles.size > 1
td td
= completion.display_relative_time = completion.display_relative_time
td td
- if !completion.puzzle.brand.blank?
| #{completion.puzzle.name} - #{completion.puzzle.brand} | #{completion.puzzle.name} - #{completion.puzzle.brand}
- else
| #{completion.puzzle.name}
td td
a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant.id) a.btn.btn-sm.btn-secondary.me-2 href=edit_contest_completion_path(@contest, completion, contestant_id: contestant.id)
| Edit = t("helpers.buttons.edit")
= link_to "Delete", contest_completion_path(contest, completion, contestant_id: contestant.id), = link_to t("helpers.buttons.delete"), contest_completion_path(contest, completion, contestant_id: contestant.id),
data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary" data: { turbo_method: :delete }, class: "btn btn-sm btn-secondary"
.row .row
.col .col
a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id) a.btn.btn-primary href=new_contest_completion_path(@contest, contestant_id: contestant.id)
| Add completion = t("helpers.buttons.add")

View File

@@ -0,0 +1,40 @@
= form_with model: @form, url: "/contests/#{@contest.id}/import/#{@csv_import.id}" do |form|
.row.mb-3
.col
.form-floating
= form.select :name_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select"
= form.label :name_column
= t("contestants.import.name_column")
.row.mb-3
.col
.form-floating
= form.select :email_column, [[t("helpers.none"), -1]] + Array.new(@content[0].count) {|i| [t("helpers.field") + "_#{i}", i] }, {}, class: "form-select"
= form.label :email_column
= t("contestants.import.email_column")
.row.mb-3
.col
= form.submit t("helpers.buttons.confirm"), class: "btn btn-primary"
.row.g-3
.col
table.table.table-striped.table-hover
thead
tr
- @content[0].each_with_index do |_, i|
th scope="col"
= t("helpers.field") + "_#{i}"
th scope="col" style="white-space: nowrap"
= t("contestants.import.import_column")
tbody
- @content.each_with_index do |row, i|
tr scope="row"
- row.each do |value|
td
= value
td
.form-check.form-switch
= form.check_box "row_#{i}".to_sym, class: "form-check-input", checked: true

View File

@@ -1 +1 @@
= render "form", contest: @contest, contestant: @contestant, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/contestants/#{@contestant.id}" = render "form", contest: @contest, contestant: @contestant, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/contestants/#{@contestant.id}"

View File

@@ -0,0 +1,19 @@
= form_with(model: @csv_import, url: contest_import_path(@contest), html: { novalidate: true }) do |form|
.row.g-3
.col
.mb-3
.form-floating
= form.file_field :file, class: "form-control", accept: ".csv, text/csv"
= form.label :file
.row.g-3
.col
.mb-3
.form-floating
= form.select :separator, @csv_import.options_for_separator, {}, class: "form-select"
= form.label :separator
.row.g-3
.col
.mb-3
= form.submit t("helpers.buttons.import"), class: "btn btn-primary"

View File

@@ -1 +1 @@
= render "form", contest: @contest, contestant: @contestant, submit_text: "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,19 @@
- if @contest.categories.size > 0
.row
.col
select.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`/public/#{@contest.slug}?category=${e.target.value}`);
})

View File

@@ -1,22 +1,65 @@
h4.mt-5 = t("contests.form.general")
= form_with model: contest do |form| = form_with model: contest do |form|
.row.mb-3 .row.mb-3
.col .col
.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.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
.row.mb-3
.col
.form-check.form-switch
= form.check_box :public, class: "form-check-input"
= form.label :public
.row.mb-3 .row.mb-3
.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"
= form.label :team = form.label :team
| Team contest .form-text = t("activerecord.attributes.contest.team_description")
.form-text For UI display purposes mainly .row.mb-3 style="display: none"
.row.mb-3
.col .col
.form-check.form-switch .form-check.form-switch
= form.check_box :allow_registration, class: "form-check-input" = form.check_box :allow_registration, class: "form-check-input"
= form.label :allow_registration = form.label :allow_registration
.form-text Generates a shareable registration form for this contest .form-text = t("activerecord.attributes.contest.allow_registration_description")
.row .row
.col .col
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"
h4.mt-5 = t("contests.form.categories")
= form_with model: Category, url: "/contests/#{@contest.id}/categories" do |form|
- if @contest.categories.size > 0
.row
.col-6
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.category.name")
th
= t("activerecord.attributes.category.contestant_count")
tbody
- @contest.categories.each do |category|
tr.align-middle scope="row"
td
= category.name
td
= category.contestants.size
td
= link_to t("helpers.buttons.delete"), contest_category_path(@contest, category), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"
.row.mt-3
.col-4
.form-floating
= form.text_field :name, autocomplete: "off", value: nil, class: "form-control"
= form.label :name, class: "required"
= t("activerecord.attributes.category.new")
.row.mt-3
.col
= form.submit t("helpers.buttons.add"), class: "btn btn-primary"

View File

@@ -1 +1 @@
= render "form", contest: @contest, submit_text: "Save" = render "form", contest: @contest, submit_text: t("helpers.buttons.save")

View File

@@ -1,10 +1,10 @@
.row .row
.col .col
h4.mb-3 h4.mb-3
| Manage your contests = t("contests.index.manage_contests")
.float-end .float-end
a.btn.btn-primary.mb-4 href=new_contest_path a.btn.btn-primary.mb-4 href=new_contest_path
| Create a new contest = t("contests.index.new_contest")
.row.row-cols-1.row-cols-md-3.g-4 .row.row-cols-1.row-cols-md-3.g-4
- @contests.each do |contest| - @contests.each do |contest|
@@ -15,9 +15,14 @@
.card-header .card-header
= contest.name = contest.name
.card-body .card-body
.card-text.mb-2
= "#{contest.puzzles.length} #{t('puzzles.singular')}" if contest.puzzles.length <= 1
= "#{contest.puzzles.length} #{t('puzzles.plural')}" if contest.puzzles.length > 1
= " - #{contest.contestants.length} #{t('contestants.singular')}" if contest.contestants.length <= 1
= " - #{contest.contestants.length} #{t('contestants.plural')}" if contest.contestants.length > 1
.row .row
.col
- contest.puzzles.each do |puzzle| - contest.puzzles.each do |puzzle|
- if puzzle.image.attached? - if puzzle.image.attached?
.col = image_tag puzzle.image, style: "max-height: 50px;", class: "mb-2 me-2"
= image_tag puzzle.image, style: "max-height: 80px;"
a.stretched-link href=contest_path(contest) a.stretched-link href=contest_path(contest)

View File

@@ -1 +1 @@
= render "form", contest: @contest, submit_text: "Create" = render "form", contest: @contest, submit_text: t("helpers.buttons.create")

View File

@@ -0,0 +1,94 @@
css:
@media (max-width: 800px) {
a.btn { display: none; }
.col-5 { display: none; }
.col-6 { width: 100% !important; display: block !important; }
.small-screen-image { display: block !important; }
.container { margin-top: 2rem !important; }
}
- if @contest.puzzles.size <= 1
.row.small-screen-image style="display: none"
- @contest.puzzles.each do |puzzle|
.d-flex.flex-column.justify-content-center.mb-5
= image_tag(puzzle.image, style: "max-height: 200px; object-fit: contain") if puzzle.image.attached?
.mt-2.fs-6 style="text-align: center"
=> "#{puzzle.name} -"
= "#{puzzle.brand} #{puzzle.pieces}p"
= render "category_selector"
.row
.col-6.d-flex.flex-column style="height: calc(100vh - 180px)"
.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
td
= contestant.name
- if @contest.puzzles.size > 1
td
= contestant.completions.length
td style="position: relative"
- if index > 0 && contestant.time_seconds > 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.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
.d-flex.flex-column style="height: calc(100vh - 180px)"
.d-flex.flex-row.justify-content-center.mb-5
- @contest.puzzles.each do |puzzle|
= image_tag(puzzle.image, class: "img-fluid ms-3 me-3", style: "max-height: 220px") if puzzle.image.attached?
= render "category_selector"
.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.length
td
= contestant.display_time

View File

@@ -1,63 +1,168 @@
.row.mb-2 - if @badges.size > 0 && false
.row.mb-4
.col .col
css: .badges style="margin-top: -18px; position: absolute"
.badges { margin-top: -18px; position: absolute; }
.badges
- @badges.each do |badge| - @badges.each do |badge|
span.badge.text-bg-info.me-2 span.badge.text-bg-info.me-2
= badge = badge
.float-end
a.btn.btn-primary href=edit_contest_path(@contest)
| Edit contest
.row.mb-4 javascript:
.col-6 async function copyExtensionUrlToClipboard() {
.row await navigator.clipboard.writeText("#{message_url}?token=#{@contest.generate_token_for(:token)}");
.col alert("#{t("contests.show.url_copied")}");
h4 }
| Puzzles
.row.row-cols-1.row-cols-md-3.g-4.mb-4 .row.mb-5
- @puzzles.each do |puzzle|
.col .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")
button.btn.btn-success.ms-3 onclick="copyExtensionUrlToClipboard()"
css: css:
.card:hover { background-color: lightblue; } button > svg {
.card.h-100 margin-right: 2px;
.card-header margin-top: -3px;
= puzzle.name }
= image_tag puzzle.image if puzzle.image.attached? <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
.card-body <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"/>
p.card-text <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"/>
= puzzle.brand </svg>
a.stretched-link href=edit_contest_puzzle_path(@contest, puzzle) =< t("contests.show.copy_extension_url")
.row
.col .row.mb-4 style="height: calc(100vh - 280px)"
a.btn.btn-primary href=new_contest_puzzle_path(@contest) .col-6.d-flex.flex-column style="height: 100%"
| Add puzzle
.col-6
.row .row
.col .col
h4 h4
| Contestants = t("contestants.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_contestant_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
a.ms-2.btn-sm.btn.btn-primary href=contest_import_path(@contest) style="margin-top: -3px"
| #{t("helpers.buttons.import")}
- if @contest.categories.size > 0
.row
.col
select.mt-2.mb-2 id="categories" style="padding: 5px"
option value=-1
| Tous.tes les participant.e.s
option value=-2
| Participant.e.s sans catégorie
- @contest.categories.each do |category|
option value=category.id
= category.name
javascript:
categorySelectEl = document.getElementById('categories');
urlParams = new URLSearchParams(window.location.search);
selectedCategory = urlParams.get('category');
Array.from(categorySelectEl.children).forEach((option) => {
if (option.value == selectedCategory) option.selected = true;
});
categorySelectEl.addEventListener('change', (e) => {
window.location.replace(`#{contest_path(@contest)}?category=${e.target.value}`);
})
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover table.table.table-striped.table-hover
thead thead
tr tr
th scope="col" th
| Name = t("helpers.rank")
th scope="col" th
| Completed puzzles = t("activerecord.attributes.contestant.name")
th
= t("activerecord.attributes.contestant.completions")
th
= t("activerecord.attributes.contestant.display_time")
tbody tbody
- @contestants.each do |contestant| - @contestants.each_with_index do |contestant, index|
tr scope="row" tr scope="row"
td
= index + 1
td td
= contestant.name = contestant.name
td td
= contestant.completions.length = contestant.completions.length
td
= 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)
| Open = t("helpers.buttons.open")
a.btn.btn-sm.btn-secondary.ms-2 href=new_contest_completion_path(@contest, contestant_id: contestant.id) .col-6.d-flex.flex-column style="height: 100%"
| Add completion .row
.row.mt-4
.col .col
a.btn.btn-primary href=new_contest_contestant_path(@contest) h4
| Add contestant = t("puzzles.plural").capitalize
a.ms-3.btn.btn-sm.btn-primary href=new_contest_puzzle_path(@contest) style="margin-top: -3px"
| + #{t("helpers.buttons.add")}
table.table.table-striped.table-hover
thead
tr
th
= t("activerecord.attributes.puzzle.image")
th
= t("activerecord.attributes.puzzle.name")
th
= t("activerecord.attributes.puzzle.brand")
th
= t("activerecord.attributes.puzzle.pieces")
tbody
- @puzzles.each do |puzzle|
tr.align-middle scope="row"
td
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 48px;") if puzzle.image.attached?
td
= puzzle.name
td
= puzzle.brand
td
= puzzle.pieces
td
a.btn.btn-sm.btn-secondary href=edit_contest_puzzle_path(@contest, puzzle)
= t("helpers.buttons.edit")
- if @messages
.row.mt-5
.col
h4 = t("messages.plural").capitalize
- if @puzzles.size == 0
.row
.col.alert.alert-danger
= t("messages.warning")
.d-flex.flex-column style="overflow-y: auto"
table.table.table-striped.table-hover
thead
tr
th scope="col" style="white-space: nowrap"
= t("activerecord.attributes.message.processed")
th scope="col"
= t("activerecord.attributes.message.time")
th scope="col"
= t("activerecord.attributes.message.author")
th.w-25 scope="col"
= t("activerecord.attributes.message.text")
th.w-25 scope="col"
tbody
- @messages.each do |message|
tr.align-middle scope="row"
td style="text-align: center"
- if message.completions.size > 0
<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
= message.display_time
td
= message.author
td
= message.text
td
.d-inline-flex
- if @puzzles.size > 0
a.btn.btn-sm.btn-secondary href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
- else
a.btn.btn-sm.btn-secondary.disabled href=contest_message_convert_path(@contest, message) style="white-space: nowrap;"
= t("helpers.buttons.add_completion")
= link_to "x", contest_message_path(@contest, message), data: { turbo_method: :delete }, class: "btn btn-sm btn-danger ms-2"

View File

@@ -7,12 +7,46 @@ html
- if @current_user - if @current_user
.float-end style="margin-top: -8px;" .float-end style="margin-top: -8px;"
nav.navbar.bg-body-primary nav.navbar.bg-body-primary
a.navbar-brand href=contests_path - if @current_user.admin
| Home a.navbar-brand href=users_path class="btn btn-light" style="margin-right: 0"
a.navbar-brand href=user_path(@current_user) = t("nav.users")
| Settings a.navbar-brand href=contests_path class="btn btn-light" style="margin-right: 0"
= button_to "Log out", session_path, method: :delete = t("nav.home")
a.navbar-brand href=user_path(@current_user) class="btn btn-light"
= t("nav.settings")
= button_to t("nav.log_out"), session_path, method: :delete, class: "btn btn-danger"
h1.mb-4 = @title css:
.toast {
opacity: 0;
animation: fadeInAndOut 6s linear;
}
@keyframes fadeInAndOut {
0%, 5%, 100% { opacity: 0 }
7%, 85% { opacity: 1 }
}
javascript:
function closeToast(event) {
event.target.parentElement.parentElement.style.display = 'none';
}
.toast-container.position-fixed.p-3 style="right: 30px; top: 85px"
- flash.each do |type, msg|
.toast role="alert" aria-live="assertive" aria-atomic="true" style="display: block"
.toast-header
strong.me-auto
i.bi-bell-fill.fs-6.text-primary
=< type.humanize
small.text-body-secondary
| Just now
button.btn-close type="button" data-bs-dismiss="toast" aria-label="Close" onclick="closeToast(event)"
.toast-body
= msg
h1.mb-4
= @title
- if @action_path
a.ms-4.btn.btn-primary href=@action_path style="margin-top: -6px"
= @action_name
= yield = yield

View File

@@ -1,4 +1,10 @@
= form_with model: puzzle, url: url, method: method do |form| = form_with model: puzzle, url: url, method: method do |form|
.row.mb-3
.col.alert.alert-warning
= t("puzzles.form.fake_data_recommendation")
.row.mb-3
.col
= image_tag(puzzle.image, class: "img-fluid", style: "max-height: 256px") if puzzle.image.attached?
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
@@ -11,10 +17,32 @@
= form.label :brand, class: "required" = form.label :brand, class: "required"
.row.mb-3 .row.mb-3
.col .col
.form-text Select an image .form-floating
= form.number_field :pieces, autocomplete: "off", class: "form-control"
= form.label :pieces, class: "required"
.row.mb-3
.col
.form-text.mb-1
= t("puzzles.image_select")
= form.file_field :image, accept: "image/*", class: "form-control" = form.file_field :image, accept: "image/*", class: "form-control"
.form-text.error-message style="display: none;" id="image-error-message"
= t("puzzles.form.file_too_big")
javascript:
function setMaxUploadSize() {
const el = document.querySelector('input[type="file"]');
el.onchange = function() {
if(this.files[0].size > 2 * 1024 * 1024) {
document.getElementById('image-error-message').style.display = 'block';
this.value = "";
} else {
document.getElementById('image-error-message').style.display = 'none';
}
};
}
setMaxUploadSize();
.row.mt-4 .row.mt-4
.col .col
- if method == :patch - if method == :patch
= link_to "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"
= form.submit submit_text, class: "btn btn-primary" = form.submit submit_text, class: "btn btn-primary"

View File

@@ -1 +1 @@
= render "form", contest: @contest, puzzle: @puzzle, submit_text: "Save", method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}" = render "form", contest: @contest, puzzle: @puzzle, submit_text: t("helpers.buttons.save"), method: :patch, url: "/contests/#{@contest.id}/puzzles/#{@puzzle.id}"

View File

@@ -1 +1 @@
= render "form", contest: @contest, puzzle: @puzzle, submit_text: "Add", method: :post, url: "/contests/#{@contest.id}/puzzles" = render "form", contest: @contest, puzzle: @puzzle, submit_text: t("helpers.buttons.add"), method: :post, url: "/contests/#{@contest.id}/puzzles"

View File

@@ -1,11 +0,0 @@
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
<%= form_with url: session_path do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
<%= form.submit "Sign in" %>
<% end %>
<br>
<%= link_to "Forgot password?", new_password_path %>

View File

@@ -0,0 +1,15 @@
= form_with url: session_path do |form|
.row.mb-3
.col
.form-floating
= form.email_field :email_address, autocomplete: "username", required: true, autofocus: true, class: "form-control"
= form.label :email_address, class: "required"
= t("activerecord.attributes.session.email_address")
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "current-password", required: true, autofocus: true, class: "form-control", maxlength: 72
= form.label :password, class: "required"
= t("activerecord.attributes.session.password")
= form.submit t("helpers.buttons.sign_in")

View File

@@ -1,4 +1,7 @@
= form_with model: user do |form| = form_with model: user, method: method do |form|
- if method == :patch
h4 = t("users.edit.general_section")
.row.mb-3 .row.mb-3
.col .col
.input-group .input-group
@@ -6,10 +9,34 @@
.form-floating .form-floating
= form.text_field :username, autocomplete: "off", class: "form-control" = form.text_field :username, autocomplete: "off", class: "form-control"
= form.label :username, class: "required" = form.label :username, class: "required"
.row.mb-3 .row.mb-3
.col .col
.form-floating .form-floating
= form.text_field :email_address, autocomplete: "off", class: "form-control", type: "email" = form.text_field :email_address, autocomplete: "off", class: "form-control"
= form.label :email_address, class: "required" = form.label :email_address, class: "required"
div
= form.submit "Save", class: "btn btn-primary" .row.mb-3
.col
.form-floating
= form.select :lang, Languages::AVAILABLE_LANGUAGES.map { |lang| [ lang[:name], lang[:id] ] }, {}, class: "form-select"
= form.label :lang
- if method == :post
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"
- if method == :patch
h4.mt-5 = t("users.edit.password_section")
= form_with model: user, method: method do |form|
.row.mb-3
.col
.form-floating
= form.password_field :password, autocomplete: "off", class: "form-control"
= form.label :password, class: "required"
= form.submit t("helpers.buttons.save"), class: "btn btn-primary"

View File

@@ -1 +1 @@
= render "form", user: @user = render "form", user: @user, method: :patch

View File

@@ -0,0 +1,27 @@
table.table.table-striped.table-hover
thead
tr
th scope="col"
| Id
th scope="col"
| Name
th scope="col"
| Admin?
th scope="col"
| # contests
tbody
- @users.each do |user|
tr scope="row"
td
= user.id
td
= user.username
td
= user.admin ? "Yes" : "No"
td
= user.contests.length
.row
.col
a.btn.btn-primary href=new_user_path
| New user

View File

@@ -1 +1 @@
= render "form", user: @user = render "form", user: @user, method: :post

View File

@@ -14,6 +14,7 @@ module PuzzleScoreboard
# Please, add to the `ignore` list any other `lib` subdirectories that do # Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded. # not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example. # Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_paths << Rails.root.join("lib")
config.autoload_lib(ignore: %w[assets tasks]) config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here. # Configuration for the application, engines, and railties goes here.
@@ -23,5 +24,8 @@ module PuzzleScoreboard
# #
# config.time_zone = "Central Time (US & Canada)" # config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras") # config.eager_load_paths << Rails.root.join("extras")
config.i18n.default_locale = :en
config.i18n.available_locales = [ :en, :fr ]
end end
end end

View File

@@ -2,9 +2,9 @@ ActionView::Base.field_error_proc = proc do |html_tag, instance|
if html_tag.include? "<label" if html_tag.include? "<label"
appended_html = "" appended_html = ""
if instance.error_message.is_a?(Array) if instance.error_message.is_a?(Array)
appended_html = "<div class='error-message form-text'>#{instance.error_message.map(&:humanize).uniq.join(", ")}</div>" appended_html = "<div class='error-message form-text'>#{instance.error_message.map(&:capitalize).uniq.join(", ")}</div>"
else else
appended_html = "<div class='error-message form-text'>#{instance.error_message.humanize}</div>" appended_html = "<div class='error-message form-text'>#{instance.error_message.capitalize}</div>"
end end
html_tag + appended_html.html_safe html_tag + appended_html.html_safe
else else

View File

@@ -0,0 +1,107 @@
# FriendlyId Global Configuration
#
# Use this to set up shared configuration options for your entire application.
# Any of the configuration options shown here can also be applied to single
# models by passing arguments to the `friendly_id` class method or defining
# methods in your model.
#
# To learn more, check out the guide:
#
# http://norman.github.io/friendly_id/file.Guide.html
FriendlyId.defaults do |config|
# ## Reserved Words
#
# Some words could conflict with Rails's routes when used as slugs, or are
# undesirable to allow as slugs. Edit this list as needed for your app.
config.use :reserved
config.reserved_words = %w[new edit index session login logout users admin
stylesheets assets javascripts images]
# This adds an option to treat reserved words as conflicts rather than exceptions.
# When there is no good candidate, a UUID will be appended, matching the existing
# conflict behavior.
config.treat_reserved_as_conflict = true
# ## Friendly Finders
#
# Uncomment this to use friendly finders in all models. By default, if
# you wish to find a record by its friendly id, you must do:
#
# MyModel.friendly.find('foo')
#
# If you uncomment this, you can do:
#
# MyModel.find('foo')
#
# This is significantly more convenient but may not be appropriate for
# all applications, so you must explicitly opt-in to this behavior. You can
# always also configure it on a per-model basis if you prefer.
#
# Something else to consider is that using the :finders addon boosts
# performance because it will avoid Rails-internal code that makes runtime
# calls to `Module.extend`.
config.use :finders
# ## Slugs
#
# Most applications will use the :slugged module everywhere. If you wish
# to do so, uncomment the following line.
config.use :slugged
# By default, FriendlyId's :slugged addon expects the slug column to be named
# 'slug', but you can change it if you wish.
#
# config.slug_column = 'slug'
#
# By default, slug has no size limit, but you can change it if you wish.
#
# config.slug_limit = 255
#
# When FriendlyId can not generate a unique ID from your base method, it appends
# a UUID, separated by a single dash. You can configure the character used as the
# separator. If you're upgrading from FriendlyId 4, you may wish to replace this
# with two dashes.
#
# config.sequence_separator = '-'
#
# Note that you must use the :slugged addon **prior** to the line which
# configures the sequence separator, or else FriendlyId will raise an undefined
# method error.
#
# ## Tips and Tricks
#
# ### Controlling when slugs are generated
#
# As of FriendlyId 5.0, new slugs are generated only when the slug field is
# nil, but if you're using a column as your base method can change this
# behavior by overriding the `should_generate_new_friendly_id?` method that
# FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave
# more like 4.0.
# Note: Use(include) Slugged module in the config if using the anonymous module.
# If you have `friendly_id :name, use: slugged` in the model, Slugged module
# is included after the anonymous module defined in the initializer, so it
# overrides the `should_generate_new_friendly_id?` method from the anonymous module.
#
# config.use :slugged
# config.use Module.new {
# def should_generate_new_friendly_id?
# slug.blank? || <your_column_name_here>_changed?
# end
# }
#
# FriendlyId uses Rails's `parameterize` method to generate slugs, but for
# languages that don't use the Roman alphabet, that's not usually sufficient.
# Here we use the Babosa library to transliterate Russian Cyrillic slugs to
# ASCII. If you use this, don't forget to add "babosa" to your Gemfile.
#
# config.use Module.new {
# def normalize_friendly_id(text)
# text.to_slug.normalize! :transliterations => [:russian, :latin]
# end
# }
end

View File

@@ -28,4 +28,230 @@
# enabled: "ON" # enabled: "ON"
en: en:
hello: "Hello world" activemodel:
errors:
models:
forms/csv_conversion_form:
attributes:
name_column:
blank: "Participant names are required"
greater_than: "Participant names are required"
activerecord:
attributes:
category:
contestant_count: Contestants count
new: New category
name: Category
completion:
contestant: Participant
display_time: Time
display_time_from_start: Time since start
display_relative_time: Time for this puzzle
puzzle: Puzzle
contest:
lang: Language for the public scoreboard
name: Name
public: Enable the public scoreboard
team: Team contest
team_description: For UI display purposes mainly
allow_registration: Allow registration
allow_registration_description: Generates a shareable registration form for this contest
contestant:
completions: completions
display_time: Time
email: Email
name: Name
email_description: Optional. Used for sending emails through this app, or for identifying participants whose gmeet handle doesn't match their registered name.
csv_import:
file: File
separator: Separator
message:
author: Author
processed: Processed?
text: Content
time: Time
puzzle:
brand: Brand
image: Image
name: Name
pieces: Number of pieces
session:
email_address: Email address
password: Password
user:
username: Username
email_address: Email address
lang: Language
password: New password
errors:
models:
completion:
attributes:
contestant_id:
taken: "This contestant has already completed the puzzle"
display_time_from_start:
blank: Mandatory
invalid: "Allowed formats: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "This contestant has already completed this puzzle"
contest:
attributes:
name:
blank: The contest name cannot be empty
contestant:
attributes:
name:
blank: The participant name cannot be empty
csv_import:
attributes:
file:
blank: "No file selected"
empty: "This file is empty"
not_a_csv_file: "it must be a CSV file"
puzzle:
attributes:
name:
blank: The puzzle name cannot be empty
pieces:
blank: It's mandatory to provide the number of pieces
user:
attributes:
email_address:
blank: Your email cannot be empty
username:
blank: Your username cannot be empty
categories:
destroy:
notice: Category deleted
new:
error: The category name can't be empty
notice: Category added
completions:
destroy:
notice: Completion deleted
edit:
notice: Completion updated
title: Edit completion
new:
notice: Completion added
title: New completion
singular: completion
contests:
destroy:
notice: Contest deleted
edit:
notice: Contest updated
title: Edit contest settings
form:
categories: Participant categories
general: General parameters
index:
title: Welcome %{username}!
manage_contests: Manage my contests
new_contest: Create a new contest
new:
notice: Contest added
title: New jigsaw puzzle contest
scoreboard:
refresh: Activate auto-refresh (every 5s)
title: "%{name}"
show:
title: "%{name}"
add_participant: Add participant
add_puzzle: Add puzzle
copy_extension_url: Copy the URL for connecting from the browser extension
open_public_scoreboard: Open public scoreboard
public_scoreboard_disabled: The public scoreboard is disabled
url_copied: URL copied to the clipboard
contestants:
convert_csv:
title: Import participants
destroy:
notice: Participant deleted
edit:
notice: Participant updated
title: Participant
team_title: Teams
finalize_import:
title: Import participants
import:
email_column: Participant email
import_column: Import?
name_column: Participant name
notice: Participants imported
title: Import participants
new:
notice: Participant added
title: New participant
team_title: New team
singular: participant
plural: participants
teams:
singular: team
plural: teams
upload_csv:
title: Import participants
helpers:
badges:
registration: "registration"
team: "team"
buttons:
add: "Add"
add_completion: "Add completion"
back: "⬅ Back to the contest"
back_to_contestant: "⬅ Back to the participant"
confirm: "Confirm"
create: "Create"
delete: "Delete"
edit: "Edit"
import: CSV Import
open: Open
refresh: Refresh
sign_in: Sign in
save: Save
field: Field
none: No field selected
rank: Rank
messages:
convert:
title: New completion
destroy:
notice: Message deleted
plural: "messages"
singular: "message"
warning: "You first need to add a puzzle before converting messages to completions."
nav:
users: Users
home: My contests
settings: Settings
log_out: Log out
puzzles:
destroy:
notice: Puzzle deleted
edit:
notice: Puzzle updated
title: Edit contest puzzle
form:
fake_data_recommendation: It is recommended to first enter a fake name and image, and to use the real ones only once the contest starts.
file_too_big: File too big! Maximum allowed size is 2M
image_select: Select an image
new:
notice: Puzzle added
title: New contest puzzle
singular: puzzle
plural: puzzles
sessions:
new:
notice: Login successful
title: "Login to the Public Scoreboard app"
users:
edit:
notice: Settings updated
title: "My settings"
general_section: "General settings"
password_section: "Change password"
index:
title: "All users"
new:
notice: User created
title: "New user"

228
config/locales/fr.yml Normal file
View File

@@ -0,0 +1,228 @@
fr:
activemodel:
errors:
models:
forms/csv_conversion_form:
attributes:
name_column:
blank: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
greater_than: "Choisir une colonne pour les noms des participant.e.s est nécessaire"
activerecord:
attributes:
category:
contestant_count: Nombre de participant.e.s
new: Nouvelle catégorie
name: Catégorie
completion:
contestant_id: Participant.e
display_time: Temps
display_time_from_start: Temps depuis le début
display_relative_time: Temps pour ce puzzle
puzzle: Puzzle
contest:
lang: Langue pour le classement public
name: Nom
public: Activer le classement public
team: Concours par équipes
team_description: Principalement pour des raisons d'affichage
allow_registration: Autoriser l'inscription via l'interface
allow_registration_description: Génère un formulaire d'inscription pour ce concours
contestant:
completions: Complétions
display_time: Temps
email: Email
name: Nom
email_description: Optionnel. Utile pour envoyer des emails aux participant.e.s depuis cette app, ou pour reconnaître les pseudos gmeet quand ils ne correspondent pas au nom préalablement entré.
csv_import:
file: Fichier
separator: Délimiteur
message:
author: Auteur.ice
processed: Traité ?
text: Contenu
time: Temps
puzzle:
brand: Marque
image: Image
name: Nom
pieces: Nombre de pièces
session:
email_address: Adresse email
password: Mot de passe
user:
username: Nom d'utilisateur.ice
email_address: Adresse email
lang: Langue de l'interface
password: Nouveau mot de passe
errors:
models:
completion:
attributes:
contestant_id:
taken: "Ce.tte participant.e a déjà complété le puzzle"
display_time_from_start:
blank: Obligatoire
invalid: "Formats autorisés: xx:xx:xx, x:xx:xx, xx:xx, x:xx, xx"
puzzle_id:
taken: "Ce.tte participant.e a déjà complété ce puzzle"
contest:
attributes:
name:
blank: Le nom du concours ne peut pas être vide
contestant:
attributes:
name:
blank: Le nom du ou de la participant.e ne peut pas être vide
csv_import:
attributes:
file:
blank: "Aucun fichier sélectionné"
empty: "Ce fichier est vide"
not_a_csv_file: "Le fichier doit être au format CSV"
puzzle:
attributes:
name:
blank: Le nom du puzzle est obligatoire
pieces:
blank: Il est obligatoire d'indiquer le nombre de pièces
user:
attributes:
email_address:
blank: L'email est obligatoire
username:
blank: Le nom d'utilisateur.ice est obligatoire
categories:
destroy:
notice: Catégorie supprimée
new:
error: Le nom de la catégorie ne peut pas être vide
notice: Catégorie ajoutée
completions:
destroy:
notice: Complétion supprimée
edit:
notice: Complétion modifiée
title: Modifier la complétion
new:
notice: Complétion ajoutée
title: Ajout d'une complétion
singular: complétion
contests:
destroy:
notice: Concours supprimé
edit:
notice: Concours modifié
title: Paramètres du concours
form:
categories: Catégories de participant.e.s
general: Paramètres généraux
index:
title: Bienvenue %{username} !
manage_contests: Mes concours de puzzle
new_contest: Créer un nouveau concours
new:
notice: Concours ajouté
title: Nouveau concours
scoreboard:
refresh: Activer le rafraichissement automatique de la page (toutes les 5s)
title: "%{name}"
show:
title: "%{name}"
add_participant: Ajouter un.e participant.e
add_puzzle: Ajouter un puzzle
copy_extension_url: Copier l'URL pour la connexion depuis l'extension web
open_public_scoreboard: Ouvrir le classement public
public_scoreboard_disabled: Le classement public n'est pas activé
url_copied: LURL a été copiée dans le presse-papier
contestants:
convert_csv:
title: Importer des participant.e.s
destroy:
notice: Participant.e supprimé.e
edit:
notice: Participant.e modifié.e
title: Participant.e
team_title: Équipe
finalize_import:
title: Importer des participant.e.s
import:
email_column: Email des participant.e.s
import_column: Importer ?
name_column: Noms des participant.e.s
notice: Participant.e.s importé.e.s
title: Importer des participant.e.s
new:
notice: Participant.e ajouté.e
title: Nouveau.elle participant.e
team_title: Nouvelle équipe
singular: participant.e
plural: participant.e.s
teams:
singular: équipe
plural: équipes
upload_csv:
title: Importer des participant.e.s
helpers:
badges:
registration: "auto-inscription"
team: "équipes"
buttons:
add: "Ajouter"
add_completion: "Convertir"
back: "⬅ Revenir au concours"
back_to_contestant: "⬅ Revenir au/à la participant.e"
confirm: "Confirmer"
create: "Créer"
delete: "Supprimer"
edit: "Modifier"
import: Importer un CSV
open: Détails
refresh: Rafraîchir
sign_in: Se connecter
save: Modifier
field: Champ
none: Aucun champ sélectionné
rank: Rang
messages:
convert:
title: Ajout d'une complétion
destroy:
notice: Message supprimé
plural: "messages"
singular: "message"
warning: "Au moins un puzzle doit être ajouté avant de pouvoir convertir des messages en complétions."
nav:
users: Utilisateur.ices
home: Mes concours
settings: Paramètres
log_out: Déconnexion
puzzles:
destroy:
notice: Puzzle supprimé
edit:
notice: Puzzle modifié
title: Modifier le puzzle
form:
fake_data_recommendation: Il est recommendé d'entrer de faux noms et images, et de mettre les vrais uniquement quand le concours démarre.
file_too_big: La taille de l'image dépasse la taille maximum autorisée de 2M
image_select: Choisis une image
new:
notice: Puzzle ajouté
title: Nouveau puzzle
singular: puzzle
plural: puzzles
sessions:
new:
notice: Connection réussie
title: "Se connecter à l'app Public Scoreboard"
users:
edit:
notice: Paramètres modifiés
title: "Mes paramètres"
general_section: "Paramètres globaux"
password_section: "Modifier mon mot de passe"
index:
title: "Tous.tes les utilisateur.ices"
new:
notice: Utilisateur.ice ajouté.e
title: "Nouveau.elle utilisateur.ice"

View File

@@ -9,11 +9,26 @@ Rails.application.routes.draw do
root "contests#index" root "contests#index"
resources :contests do resources :contests do
resources :categories, only: [ :create, :destroy ]
resources :completions resources :completions
resources :contestants resources :contestants
resources :puzzles resources :puzzles
resources :messages, only: :destroy do
get "convert", to: "messages#convert"
end
get "import", to: "contestants#import"
post "import", to: "contestants#upload_csv"
get "import/:id", to: "contestants#convert_csv"
post "import/:id", to: "contestants#finalize_import"
end end
resources :passwords, param: :token resources :passwords, param: :token
resource :session resource :session
resources :users resources :users
options "connect", to: "messages#cors_preflight_check"
options "message", to: "messages#cors_preflight_check"
post "connect", to: "messages#connect"
post "message", to: "messages#create"
get "public/:id", to: "contests#scoreboard"
end end

View File

@@ -0,0 +1,5 @@
class AddAdminToUser < ActiveRecord::Migration[8.0]
def change
add_column :users, :admin, :boolean, null: false, default: false
end
end

View File

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

View File

@@ -0,0 +1,6 @@
class AddDisplayTimesToCompletions < ActiveRecord::Migration[8.0]
def change
add_column :completions, :display_time_from_start, :string
add_column :completions, :display_relative_time, :string
end
end

View File

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

View File

@@ -0,0 +1,5 @@
class RemoveSlugFromContests < ActiveRecord::Migration[8.0]
def change
remove_column :contests, :slug, :string
end
end

View File

@@ -0,0 +1,6 @@
class AddSlugToContests < ActiveRecord::Migration[8.0]
def change
add_column :contests, :slug, :string
add_index :contests, :slug, unique: true
end
end

View File

@@ -0,0 +1,21 @@
MIGRATION_CLASS =
if ActiveRecord::VERSION::MAJOR >= 5
ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"]
else
ActiveRecord::Migration
end
class CreateFriendlyIdSlugs < MIGRATION_CLASS
def change
create_table :friendly_id_slugs do |t|
t.string :slug, null: false
t.integer :sluggable_id, null: false
t.string :sluggable_type, limit: 50
t.string :scope
t.datetime :created_at
end
add_index :friendly_id_slugs, [ :sluggable_type, :sluggable_id ]
add_index :friendly_id_slugs, [ :slug, :sluggable_type ], length: { slug: 140, sluggable_type: 50 }
add_index :friendly_id_slugs, [ :slug, :sluggable_type, :scope ], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true
end
end

View File

@@ -0,0 +1,5 @@
class AddLangToUser < ActiveRecord::Migration[8.0]
def change
add_column :users, :lang, :string, default: 'en'
end
end

View File

@@ -0,0 +1,11 @@
class CreateMessages < ActiveRecord::Migration[8.0]
def change
create_table :messages do |t|
t.integer :time_seconds, null: false
t.belongs_to :contest, null: false, foreign_key: true
t.string :text, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,12 @@
class AddAuthorToMessage < ActiveRecord::Migration[8.0]
def change
add_column :messages, :author, :string
Message.find_each do |message|
message.author = "Unknown"
message.save
end
change_column_null :messages, :author, true
end
end

View File

@@ -0,0 +1,12 @@
class AddDisplayTimeToMessage < ActiveRecord::Migration[8.0]
def change
add_column :messages, :display_time, :string
Message.find_each do |message|
message.display_time = "12:30"
message.save
end
change_column_null :messages, :display_time, true
end
end

View File

@@ -0,0 +1,9 @@
class CreateCsvImports < ActiveRecord::Migration[8.0]
def change
create_table :csv_imports do |t|
t.string :separator, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,5 @@
class AddContentToCsvImport < ActiveRecord::Migration[8.0]
def change
add_column :csv_imports, :content, :string, null: false
end
end

View File

@@ -0,0 +1,15 @@
class AddTimeSecondsToContestant < ActiveRecord::Migration[8.0]
def change
add_column :contestants, :time_seconds, :integer
Contestant.find_each do |contestant|
contestant.time_seconds = 0
contestant.completions.each do |completion|
contestant.time_seconds += completion.time_seconds
end
contestant.save
end
change_column_null :contestants, :time_seconds, true
end
end

View File

@@ -0,0 +1,5 @@
class AddMessageRefToCompletion < ActiveRecord::Migration[8.0]
def change
add_reference :completions, :message, foreign_key: true
end
end

View File

@@ -0,0 +1,5 @@
class AddLangToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :lang, :string, default: 'en'
end
end

View File

@@ -0,0 +1,5 @@
class AddPublicToContest < ActiveRecord::Migration[8.0]
def change
add_column :contests, :public, :boolean, default: false
end
end

View File

@@ -0,0 +1,12 @@
class AddPiecesToPuzzle < ActiveRecord::Migration[8.0]
def change
add_column :puzzles, :pieces, :integer
Puzzle.find_each do |puzzle|
puzzle.pieces = 500
puzzle.save
end
change_column_null :puzzles, :pieces, false
end
end

View File

@@ -0,0 +1,15 @@
class CreateCategories < ActiveRecord::Migration[8.0]
def change
create_table :categories do |t|
t.string :name
t.belongs_to :contest, null: false, foreign_key: true
t.timestamps
end
create_join_table :categories, :contestants do |t|
t.index :category_id
t.index :contestant_id
end
end
end

62
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_03_20_093759) do ActiveRecord::Schema[8.0].define(version: 2025_07_14_115208) 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
@@ -39,6 +39,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_20_093759) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end end
create_table "categories", force: :cascade do |t|
t.string "name"
t.integer "contest_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["contest_id"], name: "index_categories_on_contest_id"
end
create_table "categories_contestants", id: false, force: :cascade do |t|
t.integer "category_id", null: false
t.integer "contestant_id", null: false
t.index ["category_id"], name: "index_categories_contestants_on_category_id"
t.index ["contestant_id"], name: "index_categories_contestants_on_contestant_id"
end
create_table "completions", force: :cascade do |t| create_table "completions", force: :cascade do |t|
t.integer "time_seconds" t.integer "time_seconds"
t.integer "contestant_id", null: false t.integer "contestant_id", null: false
@@ -46,8 +61,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_20_093759) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.string "display_time_from_start"
t.string "display_relative_time"
t.integer "message_id"
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 ["puzzle_id"], name: "index_completions_on_puzzle_id" t.index ["puzzle_id"], name: "index_completions_on_puzzle_id"
end end
@@ -57,6 +76,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_20_093759) do
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "display_time"
t.integer "time_seconds"
t.index ["contest_id"], name: "index_contestants_on_contest_id" t.index ["contest_id"], name: "index_contestants_on_contest_id"
end end
@@ -67,15 +88,49 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_20_093759) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "team", default: false t.boolean "team", default: false
t.boolean "allow_registration", default: false t.boolean "allow_registration", default: false
t.string "slug"
t.string "lang", default: "en"
t.boolean "public", default: false
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
create_table "csv_imports", force: :cascade do |t|
t.string "separator", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "content", null: false
end
create_table "friendly_id_slugs", force: :cascade do |t|
t.string "slug", null: false
t.integer "sluggable_id", null: false
t.string "sluggable_type", limit: 50
t.string "scope"
t.datetime "created_at"
t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true
t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type"
t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id"
end
create_table "messages", force: :cascade do |t|
t.integer "time_seconds", null: false
t.integer "contest_id", null: false
t.string "text", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "author"
t.string "display_time"
t.index ["contest_id"], name: "index_messages_on_contest_id"
end
create_table "puzzles", force: :cascade do |t| create_table "puzzles", force: :cascade do |t|
t.string "name" t.string "name"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "contest_id", null: false t.integer "contest_id", null: false
t.string "brand" t.string "brand"
t.integer "pieces", null: false
t.index ["contest_id"], name: "index_puzzles_on_contest_id" t.index ["contest_id"], name: "index_puzzles_on_contest_id"
end end
@@ -94,16 +149,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_20_093759) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "username" t.string "username"
t.boolean "admin", default: false, null: false
t.string "lang", default: "en"
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
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "categories", "contests"
add_foreign_key "completions", "contestants" add_foreign_key "completions", "contestants"
add_foreign_key "completions", "contests" add_foreign_key "completions", "contests"
add_foreign_key "completions", "messages"
add_foreign_key "completions", "puzzles" add_foreign_key "completions", "puzzles"
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 "puzzles", "contests" add_foreign_key "puzzles", "contests"
add_foreign_key "sessions", "users" add_foreign_key "sessions", "users"
end end

View File

@@ -0,0 +1,8 @@
# This rake task was added by annotate_rb gem.
# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this
if Rails.env.development? && ENV["ANNOTATERB_SKIP_ON_DB_TASKS"].nil?
require "annotate_rb"
AnnotateRb::Core.load_rake_tasks
end

View File

@@ -0,0 +1,23 @@
# == 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)
#
FactoryBot.define do
factory :category do
name { "MyString" }
end
end

View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: contests
#
# id :integer not null, primary key
# allow_registration :boolean default(FALSE)
# lang :string default("en")
# name :string
# public :boolean default(FALSE)
# slug :string
# team :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_contests_on_slug (slug) UNIQUE
# index_contests_on_user_id (user_id)
#
# Foreign Keys
#
# user_id (user_id => users.id)
#
FactoryBot.define do
factory :contest do
name { Faker::Company.unique.name }
end
end

View File

@@ -0,0 +1,15 @@
# == 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
#
FactoryBot.define do
factory :csv_import do
separator { 1 }
end
end

View File

@@ -0,0 +1,28 @@
# == 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)
#
FactoryBot.define do
factory :message do
time_seconds { 1 }
contest { nil }
text { "MyString" }
end
end

28
spec/factories/users.rb Normal file
View File

@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# admin :boolean default(FALSE), not null
# email_address :string not null
# lang :string default("en")
# password_digest :string not null
# username :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email_address (email_address) UNIQUE
#
FactoryBot.define do
factory :user do
username { Faker::Internet.unique.username }
email_address { Faker::Internet.unique.email }
password { Faker::Internet.unique.password(min_length: 12, max_length: 18) }
end
trait :admin do
admin { true }
end
end

View File

@@ -0,0 +1,78 @@
require 'rails_helper'
RSpec.feature "Contests", type: :feature do
let!(:user) { create(:user) }
before do
login(user)
end
context "index" do
let!(:first_contest) { create(:contest, user: user) }
let!(:second_contest) { create(:contest, user: user) }
it "should list existing contests and offer to open them" do
visit contests_path
expect(page).to have_content(I18n.t("contests.index.title", username: user.username))
expect(page).to have_content(first_contest.name)
expect(page).to have_content(second_contest.name)
find(".stretched-link[href=\"/contests/#{first_contest.friendly_id}\"]").click
expect(page).to have_current_path("/contests/#{first_contest.friendly_id}")
end
it "should offer to create a new contest" do
visit contests_path
click_link I18n.t("contests.index.new_contest")
expect(page).to have_current_path("/contests/new")
end
end
context "new" do
it "should prevent creating contests without a name" do
visit new_contest_path
click_button I18n.t("helpers.buttons.create")
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
end
it "should allow creating new contests with valid parameters" do
visit new_contest_path
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
click_button I18n.t("helpers.buttons.create")
expect(page).to have_current_path(contest_path(Contest.find_by(name: "Contest name")))
end
end
context "edit" do
let!(:contest) { create(:contest, user: user) }
it "should prevent editing contests without a name" do
visit edit_contest_path(contest)
fill_in I18n.t("activerecord.attributes.contest.name"), with: ""
click_button I18n.t("helpers.buttons.save")
expect(page).to have_content(I18n.t("activerecord.errors.models.contest.attributes.name.blank"))
end
it "should allow editing contests with valid parameters" do
visit edit_contest_path(contest)
fill_in I18n.t("activerecord.attributes.contest.name"), with: "Contest name"
click_button I18n.t("helpers.buttons.save")
expect(page).to have_current_path(contest_path(contest))
end
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.feature "Login", type: :feature do
context "visiting the login page" do
let!(:user) { create(:user) }
it "should log in the user with the correct credentials" do
visit '/'
fill_in "Email address", with: user.email_address
fill_in "Password", with: user.password
click_button "Sign in"
expect(page).not_to have_content(I18n.t("sessions.new.title"))
end
it "should fail to log in the user with an incorrect email address" do
visit '/'
fill_in "Email address", with: Faker::Internet.unique.email
fill_in "Password", with: user.password
click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title"))
end
it "should fail to log in the user with an incorrect password" do
visit '/'
fill_in "Email address", with: user.email_address
fill_in "Password", with: Faker::Internet.unique.password
click_button "Sign in"
expect(page).to have_content(I18n.t("sessions.new.title"))
end
end
end

View File

@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.feature "Users", type: :feature do
context "when the user is a regular user" do
let!(:user) { create(:user) }
let!(:contest) { create(:contest, user: user) }
before do
login(user)
end
it "should not see a link to all users" do
visit root_path
expect(page).not_to have_content(I18n.t("nav.users"))
end
it "should not be able to see the user list" do
visit users_path
expect(page).not_to have_content(I18n.t("users.index.title"))
end
it "should be able to create a new contest" do
visit root_path
click_link "Create a new contest"
expect(page).to have_content(I18n.t("contests.new.title"))
end
it "should be able to open an existing contest" do
visit root_path
expect(page).to have_content(contest.name)
find("div.card", text: contest.name).find("a").click
expect(page).to have_content(I18n.t("contests.show.title", name: contest.name))
end
end
context "when the user is an admin" do
let!(:admin) { create(:user, :admin) }
let!(:user) { create(:user) }
before do
login(admin)
end
it "should see a link to all users" do
visit root_path
expect(page).to have_content(I18n.t("nav.users"))
end
it "should be able to see the user list" do
visit users_path
expect(page).to have_content(I18n.t("users.index.title"))
expect(page).to have_content(user.username)
end
end
end

View File

@@ -0,0 +1,23 @@
# == 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

@@ -0,0 +1,15 @@
# == 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

@@ -0,0 +1,26 @@
# == 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

Some files were not shown because too many files have changed in this diff Show More