From 35b304d91393b95ebd175cf12489da5383158784 Mon Sep 17 00:00:00 2001 From: sto Date: Sun, 26 Jul 2026 14:01:29 +0200 Subject: [PATCH] Events: initial implementation --- app/controllers/events_controller.rb | 56 +++++++++++++++++++ app/helpers/events_helper.rb | 2 + app/models/contest.rb | 10 +++- app/models/contestant.rb | 19 +++---- app/models/event.rb | 26 +++++++++ app/models/user.rb | 1 + app/policies/event_policy.rb | 25 +++++++++ app/views/events/_form.html.slim | 22 ++++++++ app/views/events/index.html.slim | 36 ++++++++++++ app/views/events/new.html.slim | 1 + app/views/events/show.html.slim | 28 ++++++++++ config/locales/en.yml | 24 ++++++++ config/locales/fr.yml | 24 ++++++++ config/routes.rb | 7 +-- db/migrate/20260725121740_create_events.rb | 7 +++ .../20260725122117_add_name_to_event.rb | 5 ++ .../20260725122358_add_event_to_contest.rb | 5 ++ .../20260725122559_add_user_to_event.rb | 5 ++ ...26112839_add_shared_scoreboard_to_event.rb | 5 ++ .../20260726114604_add_lang_to_event.rb | 5 ++ db/schema.rb | 19 ++++++- spec/factories/contests.rb | 9 ++- spec/factories/events.rb | 25 +++++++++ spec/helpers/events_helper_spec.rb | 15 +++++ spec/models/event_spec.rb | 25 +++++++++ spec/requests/events_spec.rb | 7 +++ 26 files changed, 389 insertions(+), 24 deletions(-) create mode 100644 app/controllers/events_controller.rb create mode 100644 app/helpers/events_helper.rb create mode 100644 app/models/event.rb create mode 100644 app/policies/event_policy.rb create mode 100644 app/views/events/_form.html.slim create mode 100644 app/views/events/index.html.slim create mode 100644 app/views/events/new.html.slim create mode 100644 app/views/events/show.html.slim create mode 100644 db/migrate/20260725121740_create_events.rb create mode 100644 db/migrate/20260725122117_add_name_to_event.rb create mode 100644 db/migrate/20260725122358_add_event_to_contest.rb create mode 100644 db/migrate/20260725122559_add_user_to_event.rb create mode 100644 db/migrate/20260726112839_add_shared_scoreboard_to_event.rb create mode 100644 db/migrate/20260726114604_add_lang_to_event.rb create mode 100644 spec/factories/events.rb create mode 100644 spec/helpers/events_helper_spec.rb create mode 100644 spec/models/event_spec.rb create mode 100644 spec/requests/events_spec.rb diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 0000000..a63b7ab --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,56 @@ +class EventsController < ApplicationController + before_action :set_event, only: %i[ destroy show ] + + def index + authorize :event + + @events = current_user.events.order(created_at: :desc) + @contests_without_event = current_user.contests.where(event: nil).size + @title = I18n.t("events.index.title", username: current_user.username) + end + + def new + authorize :event + + @event = Event.new + @title = I18n.t("events.new.title") + @nonav = true + end + + def create + authorize :event + + @event = Event.new(new_event_params) + @event.lang = @current_user.lang + @event.user_id = current_user.id + if @event.save + redirect_to "/events/#{@event.id}", notice: t("events.new.notice") + else + @title = I18n.t("events.new.title") + @nonav = true + render :new, status: :unprocessable_entity + end + end + + def show + authorize @event + + @contests = @event.contests + @title = @event.name + end + + def destroy + authorize @event + + @event.destroy + redirect_to events_path, notice: t("events.destroy.notice") + end + + def set_event + @event = Event.find(params[:id]) + end + + def new_event_params + params.expect(event: [ :name, :shared_scoreboard ]) + end +end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb new file mode 100644 index 0000000..8a9a878 --- /dev/null +++ b/app/helpers/events_helper.rb @@ -0,0 +1,2 @@ +module EventsHelper +end diff --git a/app/models/contest.rb b/app/models/contest.rb index cd42a54..a9840bf 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -20,21 +20,25 @@ # team :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null +# event_id :integer # user_id :integer not null # # Indexes # -# index_contests_on_slug (slug) UNIQUE -# index_contests_on_user_id (user_id) +# index_contests_on_event_id (event_id) +# index_contests_on_slug (slug) UNIQUE +# index_contests_on_user_id (user_id) # # Foreign Keys # -# user_id (user_id => users.id) +# event_id (event_id => events.id) +# user_id (user_id => users.id) # class Contest < ApplicationRecord extend FriendlyId belongs_to :user + belongs_to :event, optional: true has_many :categories has_many :completions, dependent: :destroy has_many :contestants, dependent: :destroy diff --git a/app/models/contestant.rb b/app/models/contestant.rb index b126c72..3b615d1 100644 --- a/app/models/contestant.rb +++ b/app/models/contestant.rb @@ -2,16 +2,15 @@ # # Table name: contestants # -# id :integer not null, primary key -# display_time :string -# email :string -# name :string -# projected_time :string -# qrcode :string -# time_seconds :integer -# created_at :datetime not null -# updated_at :datetime not null -# contest_id :integer not null +# id :integer not null, primary key +# display_time :string +# email :string +# name :string +# qrcode :string +# time_seconds :integer +# created_at :datetime not null +# updated_at :datetime not null +# contest_id :integer not null # # Indexes # diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000..d162ece --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: events +# +# id :integer not null, primary key +# lang :string default("en") +# name :string +# shared_scoreboard :boolean +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_events_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) +# +class Event < ApplicationRecord + belongs_to :user + has_many :contests, dependent: :destroy + + validates :name, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index f1d84db..cc65169 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ # index_users_on_email_address (email_address) UNIQUE # class User < ApplicationRecord + has_many :events, dependent: :destroy has_many :contests, dependent: :destroy has_many :sessions, dependent: :destroy has_secure_password diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb new file mode 100644 index 0000000..9405b03 --- /dev/null +++ b/app/policies/event_policy.rb @@ -0,0 +1,25 @@ +class EventPolicy < ApplicationPolicy + def owner_or_admin + if record == :event + true + else + record.user.id == user.id || user.admin? + end + end + + def index? + owner_or_admin + end + + def new? + owner_or_admin + end + + def create? + owner_or_admin + end + + def show? + owner_or_admin + end +end diff --git a/app/views/events/_form.html.slim b/app/views/events/_form.html.slim new file mode 100644 index 0000000..fae5cdf --- /dev/null +++ b/app/views/events/_form.html.slim @@ -0,0 +1,22 @@ += form_with model: event, url: url, method: method do |form| + .row.mb-3 + .col + .form-floating + = form.text_field :name, autocomplete: "off", class: "form-control" + = form.label :name, class: "required" + + .row.mb-3 + .col + .form-check.form-switch + = form.check_box :shared_scoreboard, class: "form-check-input" + = form.label :shared_scoreboard + .form-text = t("activerecord.attributes.event.shared_scoreboard_description") + + .alert.alert-primary role="alert" + = t("events.form.shared_scoreboard_message") + + .row.mt-4 + .col + - if method == :patch + = link_to t("helpers.buttons.delete"), event_path(event), data: { turbo_method: :delete }, class: "btn btn-danger me-2" + = form.submit submit_text, class: "btn btn-primary" \ No newline at end of file diff --git a/app/views/events/index.html.slim b/app/views/events/index.html.slim new file mode 100644 index 0000000..5e14a96 --- /dev/null +++ b/app/views/events/index.html.slim @@ -0,0 +1,36 @@ +.row + .col + h4.mb-3 + = t("events.index.subtitle") + .float-end + a.btn.btn-primary.mb-4 href=new_event_path + = t("events.index.new") + +.alert.alert-primary role="alert" + = t("events.index.message") + +- @events.each do |event| + .row.mt-3 + .col + css: + .card:hover { background-color: lightblue; } + .card.h-100 + .card-header + = event.name + .card-body + .card-text.mb-2 + = "#{event.contests.size} " + t("events.index.contests") if event.contests.size > 1 + = "#{event.contests.size} " + t("events.index.contest") if event.contests.size <= 1 + a.stretched-link href=event_path(event) +.row.mt-3 + .col + css: + .card:hover { background-color: lightblue; } + .card.h-100 + .card-header + = t("events.index.my_contests_outside_events") + .card-body + .card-text.mb-2 + = "#{@contests_without_event} " + t("events.index.contests") if @contests_without_event > 1 + = "#{@contests_without_event} " + t("events.index.contest") if @contests_without_event <= 1 + a.stretched-link href=contests_path \ No newline at end of file diff --git a/app/views/events/new.html.slim b/app/views/events/new.html.slim new file mode 100644 index 0000000..f761947 --- /dev/null +++ b/app/views/events/new.html.slim @@ -0,0 +1 @@ += render "form", event: @event, submit_text: t("helpers.buttons.add"), method: :post, url: "/events" \ No newline at end of file diff --git a/app/views/events/show.html.slim b/app/views/events/show.html.slim new file mode 100644 index 0000000..b091cee --- /dev/null +++ b/app/views/events/show.html.slim @@ -0,0 +1,28 @@ +.row + .col + h4.mb-3 + = t("contests.index.manage_contests") + .float-end + a.btn.btn-primary.mb-4 href=new_contest_path + = t("contests.index.new_contest") + +.row.row-cols-1.row-cols-md-3.g-4 + - @contests.each do |contest| + .col + css: + .card:hover { background-color: lightblue; } + .card.h-100 + .card-header + = contest.name + .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 + .col + - contest.puzzles.each do |puzzle| + - if puzzle.image.attached? + = image_tag puzzle.image, style: "max-height: 50px;", class: "mb-2 me-2" + a.stretched-link href=contest_path(contest) \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index f981a3f..7d16948 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -82,6 +82,10 @@ en: csv_import: file: File separator: Separator + event: + name: Name + shared_scoreboard: Shared scoreboard + shared_scoreboard_description: If activated, there will be a scoreboard link specific to the event, with tabs for each contest message: author: Author processed: Processed? @@ -142,6 +146,10 @@ en: blank: "No file selected" empty: "This file is empty" not_a_csv_file: "it must be a CSV file" + event: + attributes: + name: + blank: The event name cannot be empty offline: attributes: end_image: @@ -277,6 +285,22 @@ en: plural: teams upload_csv: title: Import participants + events: + destroy: + notice: Event deleted + form: + shared_scoreboard_message: As safety measure, for each tab, no information will be shown until the first finish, or if the stopwatch for that contest is started. + index: + contest: contest + contests: contests + message: New feature! Events offer to groupe multiple contests in one management space. This also offers, if activated, to have a single scoreboard link for all contests of an event. + my_contests_outside_events: My contests not linked to any event + new: Create a new event + subtitle: My events + title: Welcome %{username}! + new: + notice: Event added + title: New event helpers: badges: registration: "registration" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c4e869e..669c72e 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -53,6 +53,10 @@ fr: csv_import: file: Fichier separator: Délimiteur + event: + name: Nom + shared_scoreboard: Classement partagé + shared_scoreboard_description: Si activé, un lien de classement partagé sera disponible pour l'événement, avec des onglets pour chacune des épreuves. message: author: Auteur.ice processed: Traité ? @@ -113,6 +117,10 @@ fr: blank: "Aucun fichier sélectionné" empty: "Ce fichier est vide" not_a_csv_file: "Le fichier doit être au format CSV" + event: + attributes: + name: + blank: Le nom de l'événement ne peut pas être vide offline: attributes: end_image: @@ -248,6 +256,22 @@ fr: plural: équipes upload_csv: title: Importer des participant.e.s + events: + destroy: + notice: Événement supprimé + form: + shared_scoreboard_message: Par mesure de sécurité, chaque onglet restera vide jusqu'à ce que soit il y a des premières complétions, soit le chronomètre de l'épreuve a été démarré. + index: + contest: épreuve + contests: épreuves + message: Nouvelle fonctionalité ! Les événements permettent de regrouper plusieurs concours/épreuves en un seul espace de gestion. Cela permet aussi, si souhaité, d'avoir un lien de classement commun à toutes les épreuces d'un même événement. + my_contests_outside_events: Mes concours non associés à un événement + new: Créer un nouvel événement + subtitle: Mes événements + title: Bienvenue %{username} ! + new: + notice: Événement ajouté + title: Nouvel événement helpers: badges: registration: "auto-inscription" diff --git a/config/routes.rb b/config/routes.rb index 0572a99..d2689da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,7 +6,7 @@ Rails.application.routes.draw do get "up" => "rails/health#show", as: :rails_health_check # Defines the root path route ("/") - root "contests#index" + root "events#index" resources :contests do get "settings/general", to: "contests#settings_general_edit" @@ -39,6 +39,7 @@ Rails.application.routes.draw do get "generate_qrcodes_pdf", to: "contestants#generate_qrcodes_pdf" get "generate_qrcodes_archive", to: "contestants#generate_qrcodes_archive" end + resources :events resources :passwords, param: :token resource :session resources :users do @@ -63,10 +64,6 @@ Rails.application.routes.draw do post "public/p/:contestant_id", to: "contestants#post_public_completion" get "public/p/:contestant_id/updated", to: "contestants#public_completion_updated" - direct :direct_test do - "https://lol.com" - end - direct :public_scoreboard do |contest| "/public/#{contest.friendly_id}/public" end diff --git a/db/migrate/20260725121740_create_events.rb b/db/migrate/20260725121740_create_events.rb new file mode 100644 index 0000000..b06e16b --- /dev/null +++ b/db/migrate/20260725121740_create_events.rb @@ -0,0 +1,7 @@ +class CreateEvents < ActiveRecord::Migration[8.0] + def change + create_table :events do |t| + t.timestamps + end + end +end diff --git a/db/migrate/20260725122117_add_name_to_event.rb b/db/migrate/20260725122117_add_name_to_event.rb new file mode 100644 index 0000000..b005f45 --- /dev/null +++ b/db/migrate/20260725122117_add_name_to_event.rb @@ -0,0 +1,5 @@ +class AddNameToEvent < ActiveRecord::Migration[8.0] + def change + add_column :events, :name, :string + end +end diff --git a/db/migrate/20260725122358_add_event_to_contest.rb b/db/migrate/20260725122358_add_event_to_contest.rb new file mode 100644 index 0000000..ea95361 --- /dev/null +++ b/db/migrate/20260725122358_add_event_to_contest.rb @@ -0,0 +1,5 @@ +class AddEventToContest < ActiveRecord::Migration[8.0] + def change + add_reference :contests, :event, foreign_key: true + end +end diff --git a/db/migrate/20260725122559_add_user_to_event.rb b/db/migrate/20260725122559_add_user_to_event.rb new file mode 100644 index 0000000..3dde756 --- /dev/null +++ b/db/migrate/20260725122559_add_user_to_event.rb @@ -0,0 +1,5 @@ +class AddUserToEvent < ActiveRecord::Migration[8.0] + def change + add_reference :events, :user, null: false, foreign_key: true + end +end diff --git a/db/migrate/20260726112839_add_shared_scoreboard_to_event.rb b/db/migrate/20260726112839_add_shared_scoreboard_to_event.rb new file mode 100644 index 0000000..b298df6 --- /dev/null +++ b/db/migrate/20260726112839_add_shared_scoreboard_to_event.rb @@ -0,0 +1,5 @@ +class AddSharedScoreboardToEvent < ActiveRecord::Migration[8.0] + def change + add_column :events, :shared_scoreboard, :boolean + end +end diff --git a/db/migrate/20260726114604_add_lang_to_event.rb b/db/migrate/20260726114604_add_lang_to_event.rb new file mode 100644 index 0000000..7271c63 --- /dev/null +++ b/db/migrate/20260726114604_add_lang_to_event.rb @@ -0,0 +1,5 @@ +class AddLangToEvent < ActiveRecord::Migration[8.0] + def change + add_column :events, :lang, :string, default: 'en' + end +end diff --git a/db/schema.rb b/db/schema.rb index ee2454f..cf58fff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_24_091108) do +ActiveRecord::Schema[8.0].define(version: 2026_07_26_114604) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -83,7 +83,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_24_091108) do t.datetime "updated_at", null: false t.string "display_time" t.integer "time_seconds" - t.string "projected_time" t.string "qrcode" t.index ["contest_id"], name: "index_contestants_on_contest_id" end @@ -106,7 +105,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_24_091108) do t.datetime "start_time" t.datetime "pause_time" t.boolean "show_stopwatch" - t.string "organizer_form" + t.boolean "organizer_form" + t.integer "event_id" + t.index ["event_id"], name: "index_contests_on_event_id" t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["user_id"], name: "index_contests_on_user_id" end @@ -118,6 +119,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_24_091108) do t.string "content", null: false end + create_table "events", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "name" + t.integer "user_id", null: false + t.boolean "shared_scoreboard" + t.string "lang", default: "en" + t.index ["user_id"], name: "index_events_on_user_id" + end + create_table "friendly_id_slugs", force: :cascade do |t| t.string "slug", null: false t.integer "sluggable_id", null: false @@ -198,7 +209,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_24_091108) do add_foreign_key "completions", "messages" add_foreign_key "completions", "puzzles" add_foreign_key "contestants", "contests" + add_foreign_key "contests", "events" add_foreign_key "contests", "users" + add_foreign_key "events", "users" add_foreign_key "messages", "contests" add_foreign_key "offlines", "completions" add_foreign_key "offlines", "contestants" diff --git a/spec/factories/contests.rb b/spec/factories/contests.rb index 2189a13..a8541a8 100644 --- a/spec/factories/contests.rb +++ b/spec/factories/contests.rb @@ -20,16 +20,19 @@ # team :boolean default(FALSE) # created_at :datetime not null # updated_at :datetime not null +# event_id :integer # user_id :integer not null # # Indexes # -# index_contests_on_slug (slug) UNIQUE -# index_contests_on_user_id (user_id) +# index_contests_on_event_id (event_id) +# index_contests_on_slug (slug) UNIQUE +# index_contests_on_user_id (user_id) # # Foreign Keys # -# user_id (user_id => users.id) +# event_id (event_id => events.id) +# user_id (user_id => users.id) # FactoryBot.define do factory :contest do diff --git a/spec/factories/events.rb b/spec/factories/events.rb new file mode 100644 index 0000000..215c10a --- /dev/null +++ b/spec/factories/events.rb @@ -0,0 +1,25 @@ +# == Schema Information +# +# Table name: events +# +# id :integer not null, primary key +# lang :string default("en") +# name :string +# shared_scoreboard :boolean +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_events_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) +# +FactoryBot.define do + factory :event do + + end +end diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb new file mode 100644 index 0000000..fdca093 --- /dev/null +++ b/spec/helpers/events_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the EventsHelper. For example: +# +# describe EventsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe EventsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb new file mode 100644 index 0000000..95ef4b5 --- /dev/null +++ b/spec/models/event_spec.rb @@ -0,0 +1,25 @@ +# == Schema Information +# +# Table name: events +# +# id :integer not null, primary key +# lang :string default("en") +# name :string +# shared_scoreboard :boolean +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer not null +# +# Indexes +# +# index_events_on_user_id (user_id) +# +# Foreign Keys +# +# user_id (user_id => users.id) +# +require 'rails_helper' + +RSpec.describe Event, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb new file mode 100644 index 0000000..a31bb18 --- /dev/null +++ b/spec/requests/events_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Events", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end