From f224da14ba2ecc63e53657824daf9ee547b39054 Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Wed, 8 Oct 2025 14:43:03 +1100 Subject: [PATCH 1/6] Sketch out potential way to model scored elections --- app/models/election.rb | 39 +++ app/models/nomination.rb | 30 ++ app/models/user.rb | 2 + app/models/vote.rb | 25 ++ db/migrate/20251007055656_create_elections.rb | 13 + .../20251007062829_create_nominations.rb | 13 + db/migrate/20251008023211_create_votes.rb | 13 + db/schema.rb | 295 ++++++++++-------- spec/factories/elections.rb | 22 ++ spec/factories/nominations.rb | 31 ++ spec/factories/votes.rb | 28 ++ spec/models/election_spec.rb | 64 ++++ spec/models/nomination_spec.rb | 29 ++ spec/models/vote_spec.rb | 26 ++ 14 files changed, 506 insertions(+), 124 deletions(-) create mode 100644 app/models/election.rb create mode 100644 app/models/nomination.rb create mode 100644 app/models/vote.rb create mode 100644 db/migrate/20251007055656_create_elections.rb create mode 100644 db/migrate/20251007062829_create_nominations.rb create mode 100644 db/migrate/20251008023211_create_votes.rb create mode 100644 spec/factories/elections.rb create mode 100644 spec/factories/nominations.rb create mode 100644 spec/factories/votes.rb create mode 100644 spec/models/election_spec.rb create mode 100644 spec/models/nomination_spec.rb create mode 100644 spec/models/vote_spec.rb diff --git a/app/models/election.rb b/app/models/election.rb new file mode 100644 index 00000000..58611731 --- /dev/null +++ b/app/models/election.rb @@ -0,0 +1,39 @@ +# == Schema Information +# +# Table name: elections +# +# id :bigint not null, primary key +# closed_at :datetime +# opened_at :datetime +# point_scale :integer default(10), not null +# position :string not null +# vacancies :integer default(1), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class Election < ApplicationRecord + has_many :nominations + has_many :votes, through: :nominations + has_many :nominated_candidates, through: :nominations, source: :nominee + + def elected_users + if nominated_candidates.count > vacancies + top_scoring_candidates + else + nominated_candidates + end + end + + private + + def top_scoring_candidates + User.where(id: + nominations + .joins(:votes) + .group('nominations.id') + .order('SUM(votes.score) DESC') + .limit(vacancies) + .pluck(:nominee_id) + ) + end +end diff --git a/app/models/nomination.rb b/app/models/nomination.rb new file mode 100644 index 00000000..cd04f807 --- /dev/null +++ b/app/models/nomination.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# Table name: nominations +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# election_id :bigint not null +# nominated_by_id :bigint not null +# nominee_id :bigint not null +# +# Indexes +# +# index_nominations_on_election_id (election_id) +# index_nominations_on_election_id_and_nominee_id (election_id,nominee_id) UNIQUE +# index_nominations_on_nominated_by_id (nominated_by_id) +# index_nominations_on_nominee_id (nominee_id) +# +# Foreign Keys +# +# fk_rails_... (election_id => elections.id) +# fk_rails_... (nominated_by_id => users.id) +# fk_rails_... (nominee_id => users.id) +# +class Nomination < ApplicationRecord + has_many :votes + belongs_to :election + belongs_to :nominee, class_name: 'User' + belongs_to :nominated_by, class_name: 'User' +end diff --git a/app/models/user.rb b/app/models/user.rb index 37bc780c..91e38bd9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -54,6 +54,8 @@ class User < ApplicationRecord has_many :memberships, dependent: :destroy has_many :emails, dependent: :destroy has_many :access_requests, foreign_key: :recorder_id, dependent: :destroy, inverse_of: :recorder + has_many :nominations_received, class_name: 'Nomination', foreign_key: :nominee_id, inverse_of: :nominee + has_many :nominations_made, class_name: 'Nomination', foreign_key: :nominated_by, inverse_of: :nominated_by scope :seeking_work, -> { where(seeking_work: true).where.not(linkedin_url: [nil, ""]) } scope :unconfirmed, -> { where(confirmed_at: nil) } diff --git a/app/models/vote.rb b/app/models/vote.rb new file mode 100644 index 00000000..d6aea23a --- /dev/null +++ b/app/models/vote.rb @@ -0,0 +1,25 @@ +# == Schema Information +# +# Table name: votes +# +# id :bigint not null, primary key +# score :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# nomination_id :bigint not null +# voter_id :bigint not null +# +# Indexes +# +# index_votes_on_nomination_id (nomination_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (voter_id => users.id) +# +class Vote < ApplicationRecord + belongs_to :nomination + belongs_to :voter, class_name: 'User' +end diff --git a/db/migrate/20251007055656_create_elections.rb b/db/migrate/20251007055656_create_elections.rb new file mode 100644 index 00000000..f4ce5fdd --- /dev/null +++ b/db/migrate/20251007055656_create_elections.rb @@ -0,0 +1,13 @@ +class CreateElections < ActiveRecord::Migration[8.0] + def change + create_table :elections do |t| + t.string :position, null: false + t.integer :vacancies, default: 1, null: false + t.integer :point_scale, default: 10, null: false + t.datetime :opened_at + t.datetime :closed_at + + t.timestamps + end + end +end diff --git a/db/migrate/20251007062829_create_nominations.rb b/db/migrate/20251007062829_create_nominations.rb new file mode 100644 index 00000000..c1c022a9 --- /dev/null +++ b/db/migrate/20251007062829_create_nominations.rb @@ -0,0 +1,13 @@ +class CreateNominations < ActiveRecord::Migration[8.0] + def change + create_table :nominations do |t| + t.references :election, null: false, foreign_key: true + t.references :nominee, null: false, foreign_key: { to_table: :users } + t.references :nominated_by, null: false, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :nominations, [:election_id, :nominee_id], unique: true + end +end diff --git a/db/migrate/20251008023211_create_votes.rb b/db/migrate/20251008023211_create_votes.rb new file mode 100644 index 00000000..1fafa188 --- /dev/null +++ b/db/migrate/20251008023211_create_votes.rb @@ -0,0 +1,13 @@ +class CreateVotes < ActiveRecord::Migration[8.0] + def change + create_table :votes do |t| + t.integer :score, null: false + t.references :voter, null: false, foreign_key: { to_table: :users } + t.references :nomination, null: false + + t.timestamps + end + + add_index :votes, [:voter_id, :nomination_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2847ff86..bce9d9a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,50 +10,50 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_22_132138) do +ActiveRecord::Schema[8.1].define(version: 2025_10_08_023211) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" create_table "access_requests", force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false t.string "name", null: false t.text "reason" - t.date "requested_on", null: false - t.date "viewed_on" t.bigint "recorder_id" - t.datetime "created_at", precision: nil, null: false + t.date "requested_on", null: false t.datetime "updated_at", precision: nil, null: false + t.date "viewed_on" t.index ["recorder_id"], name: "index_access_requests_on_recorder_id" end create_table "action_text_rich_texts", force: :cascade do |t| - t.string "name", null: false t.text "body" - t.string "record_type", null: false - t.bigint "record_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -64,11 +64,11 @@ end create_table "ahoy_events", force: :cascade do |t| - t.bigint "visit_id" - t.bigint "user_id" t.string "name" t.jsonb "properties" t.datetime "time" + t.bigint "user_id" + t.bigint "visit_id" t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time" t.index ["properties"], name: "index_ahoy_events_on_properties", opclass: :jsonb_path_ops, using: :gin t.index ["user_id"], name: "index_ahoy_events_on_user_id" @@ -76,31 +76,31 @@ end create_table "ahoy_visits", force: :cascade do |t| - t.string "visit_token" - t.string "visitor_token" - t.bigint "user_id" - t.string "ip" - t.text "user_agent" - t.text "referrer" - t.string "referring_domain" - t.text "landing_page" + t.string "app_version" t.string "browser" - t.string "os" - t.string "device_type" - t.string "country" - t.string "region" t.string "city" + t.string "country" + t.string "device_type" + t.string "ip" + t.text "landing_page" t.float "latitude" t.float "longitude" - t.string "utm_source" - t.string "utm_medium" - t.string "utm_term" - t.string "utm_content" - t.string "utm_campaign" - t.string "app_version" + t.string "os" t.string "os_version" t.string "platform" + t.text "referrer" + t.string "referring_domain" + t.string "region" t.datetime "started_at" + t.text "user_agent" + t.bigint "user_id" + t.string "utm_campaign" + t.string "utm_content" + t.string "utm_medium" + t.string "utm_source" + t.string "utm_term" + t.string "visit_token" + t.string "visitor_token" t.index ["user_id"], name: "index_ahoy_visits_on_user_id" t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at" @@ -108,48 +108,58 @@ create_table "campaign_deliveries", force: :cascade do |t| t.bigint "campaign_id" - t.bigint "membership_id" - t.datetime "delivered_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false + t.datetime "delivered_at", precision: nil, null: false + t.bigint "membership_id" t.datetime "updated_at", precision: nil, null: false t.index ["campaign_id"], name: "index_campaign_deliveries_on_campaign_id" t.index ["membership_id"], name: "index_campaign_deliveries_on_membership_id" end create_table "campaigns", force: :cascade do |t| - t.bigint "rsvp_event_id" - t.string "subject", null: false - t.string "preheader", null: false t.text "content" - t.datetime "delivered_at", precision: nil t.datetime "created_at", precision: nil, null: false + t.datetime "delivered_at", precision: nil + t.string "preheader", null: false + t.bigint "rsvp_event_id" + t.string "subject", null: false t.datetime "updated_at", precision: nil, null: false t.index ["rsvp_event_id"], name: "index_campaigns_on_rsvp_event_id" end + create_table "elections", force: :cascade do |t| + t.datetime "closed_at" + t.datetime "created_at", null: false + t.datetime "opened_at" + t.integer "point_scale", default: 10, null: false + t.string "position", null: false + t.datetime "updated_at", null: false + t.integer "vacancies", default: 1, null: false + end + create_table "emails", force: :cascade do |t| - t.bigint "user_id" - t.string "email", null: false - t.boolean "primary", default: false, null: false - t.string "unconfirmed_email" + t.datetime "confirmation_sent_at" t.string "confirmation_token" t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" t.datetime "created_at", null: false + t.string "email", null: false + t.boolean "primary", default: false, null: false + t.string "unconfirmed_email" t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["email"], name: "index_emails_on_email", unique: true t.index ["user_id"], name: "index_emails_on_user_id" end create_table "imported_members", force: :cascade do |t| - t.string "full_name", null: false - t.string "email", null: false - t.json "data", default: {}, null: false t.datetime "contacted_at", precision: nil t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.json "data", default: {}, null: false + t.string "email", null: false + t.string "full_name", null: false t.string "token", null: false t.datetime "unsubscribed_at", precision: nil + t.datetime "updated_at", precision: nil, null: false t.index ["contacted_at"], name: "index_imported_members_on_contacted_at" t.index ["email"], name: "index_imported_members_on_email" t.index ["token"], name: "index_imported_members_on_token", unique: true @@ -157,89 +167,101 @@ end create_table "memberships", force: :cascade do |t| - t.bigint "user_id", null: false + t.datetime "created_at", precision: nil, null: false t.datetime "joined_at", precision: nil, null: false t.datetime "left_at", precision: nil - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.bigint "user_id", null: false t.index ["user_id"], name: "index_memberships_on_user_id" end + create_table "nominations", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "election_id", null: false + t.bigint "nominated_by_id", null: false + t.bigint "nominee_id", null: false + t.datetime "updated_at", null: false + t.index ["election_id", "nominee_id"], name: "index_nominations_on_election_id_and_nominee_id", unique: true + t.index ["election_id"], name: "index_nominations_on_election_id" + t.index ["nominated_by_id"], name: "index_nominations_on_nominated_by_id" + t.index ["nominee_id"], name: "index_nominations_on_nominee_id" + end + create_table "posts", force: :cascade do |t| - t.bigint "user_id" - t.string "title" - t.string "slug" - t.text "content" - t.integer "status" - t.datetime "published_at" - t.integer "category" - t.datetime "publish_scheduled_at" t.datetime "archived_at" + t.integer "category" + t.text "content" t.datetime "created_at", null: false + t.datetime "publish_scheduled_at" + t.datetime "published_at" + t.string "slug" + t.integer "status" + t.string "title" t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["user_id"], name: "index_posts_on_user_id" end create_table "rsvp_events", force: :cascade do |t| - t.string "title", null: false - t.datetime "happens_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.datetime "happens_at", precision: nil, null: false t.string "link" + t.string "title", null: false + t.datetime "updated_at", precision: nil, null: false end create_table "rsvps", force: :cascade do |t| - t.bigint "rsvp_event_id", null: false + t.datetime "created_at", precision: nil, null: false t.bigint "membership_id", null: false + t.datetime "proxy_assigned_at", precision: nil + t.string "proxy_name" + t.text "proxy_signature" + t.bigint "rsvp_event_id", null: false t.string "status", default: "unknown", null: false t.string "token", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "proxy_name" - t.text "proxy_signature" - t.datetime "proxy_assigned_at", precision: nil t.index ["membership_id"], name: "index_rsvps_on_membership_id" t.index ["rsvp_event_id"], name: "index_rsvps_on_rsvp_event_id" t.index ["token"], name: "index_rsvps_on_token", unique: true end create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" @@ -249,115 +271,136 @@ end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false + t.string "task_key", null: false t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end create_table "users", id: :serial, force: :cascade do |t| - t.string "email" + t.text "address" + t.boolean "committee", default: false, null: false + t.datetime "confirmation_sent_at", precision: nil + t.string "confirmation_token" + t.datetime "confirmed_at", precision: nil t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.string "preferred_name" - t.string "full_name" - t.boolean "mailing_list", default: false, null: false - t.boolean "visible", default: false, null: false - t.string "encrypted_password" - t.string "token" - t.boolean "email_confirmed", default: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at", precision: nil - t.datetime "remember_created_at", precision: nil - t.integer "sign_in_count", default: 0, null: false t.datetime "current_sign_in_at", precision: nil - t.datetime "last_sign_in_at", precision: nil t.inet "current_sign_in_ip" - t.inet "last_sign_in_ip" - t.string "confirmation_token" - t.datetime "confirmed_at", precision: nil - t.datetime "confirmation_sent_at", precision: nil - t.string "unconfirmed_email" - t.text "address" t.datetime "deactivated_at", precision: nil - t.boolean "committee", default: false, null: false - t.json "mailing_lists", default: {}, null: false + t.string "email" + t.boolean "email_confirmed", default: false + t.string "encrypted_password" + t.string "full_name" + t.datetime "last_sign_in_at", precision: nil + t.inet "last_sign_in_ip" t.string "linkedin_url" + t.boolean "mailing_list", default: false, null: false + t.json "mailing_lists", default: {}, null: false + t.string "preferred_name" + t.datetime "remember_created_at", precision: nil + t.datetime "reset_password_sent_at", precision: nil + t.string "reset_password_token" t.boolean "seeking_work", default: false, null: false + t.integer "sign_in_count", default: 0, null: false + t.string "token" + t.string "unconfirmed_email" + t.datetime "updated_at", precision: nil, null: false + t.boolean "visible", default: false, null: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + create_table "versions", force: :cascade do |t| + t.datetime "created_at" + t.string "event", null: false + t.bigint "item_id", null: false + t.string "item_type", null: false + t.text "object" + t.string "whodunnit" + t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + end + + create_table "votes", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "nomination_id", null: false + t.integer "score", null: false + t.datetime "updated_at", null: false + t.bigint "voter_id", null: false + t.index ["nomination_id"], name: "index_votes_on_nomination_id" + t.index ["voter_id", "nomination_id"], name: "index_votes_on_voter_id_and_nomination_id", unique: true + t.index ["voter_id"], name: "index_votes_on_voter_id" + end + add_foreign_key "access_requests", "users", column: "recorder_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" @@ -366,6 +409,9 @@ add_foreign_key "campaigns", "rsvp_events" add_foreign_key "emails", "users" add_foreign_key "memberships", "users" + add_foreign_key "nominations", "elections" + add_foreign_key "nominations", "users", column: "nominated_by_id" + add_foreign_key "nominations", "users", column: "nominee_id" add_foreign_key "rsvps", "memberships" add_foreign_key "rsvps", "rsvp_events" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade @@ -374,4 +420,5 @@ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "votes", "users", column: "voter_id" end diff --git a/spec/factories/elections.rb b/spec/factories/elections.rb new file mode 100644 index 00000000..84613160 --- /dev/null +++ b/spec/factories/elections.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: elections +# +# id :bigint not null, primary key +# closed_at :datetime +# opened_at :datetime +# point_scale :integer default(10), not null +# position :string not null +# vacancies :integer default(1), not null +# created_at :datetime not null +# updated_at :datetime not null +# +FactoryBot.define do + factory :election do + position { 'President' } + closed_at { nil } + opened_at { nil } + point_scale { 10 } + vacancies { 1 } + end +end diff --git a/spec/factories/nominations.rb b/spec/factories/nominations.rb new file mode 100644 index 00000000..180a80f9 --- /dev/null +++ b/spec/factories/nominations.rb @@ -0,0 +1,31 @@ +# == Schema Information +# +# Table name: nominations +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# election_id :bigint not null +# nominated_by_id :bigint not null +# nominee_id :bigint not null +# +# Indexes +# +# index_nominations_on_election_id (election_id) +# index_nominations_on_election_id_and_nominee_id (election_id,nominee_id) UNIQUE +# index_nominations_on_nominated_by_id (nominated_by_id) +# index_nominations_on_nominee_id (nominee_id) +# +# Foreign Keys +# +# fk_rails_... (election_id => elections.id) +# fk_rails_... (nominated_by_id => users.id) +# fk_rails_... (nominee_id => users.id) +# +FactoryBot.define do + factory :nomination do + association :election + association :nominee, factory: :user + association :nominated_by, factory: :user + end +end diff --git a/spec/factories/votes.rb b/spec/factories/votes.rb new file mode 100644 index 00000000..49373eac --- /dev/null +++ b/spec/factories/votes.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: votes +# +# id :bigint not null, primary key +# score :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# nomination_id :bigint not null +# voter_id :bigint not null +# +# Indexes +# +# index_votes_on_nomination_id (nomination_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (voter_id => users.id) +# +FactoryBot.define do + factory :vote do + score { 5 } + association :nomination + association :voter, factory: :user + end +end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb new file mode 100644 index 00000000..b26424d1 --- /dev/null +++ b/spec/models/election_spec.rb @@ -0,0 +1,64 @@ +# == Schema Information +# +# Table name: elections +# +# id :bigint not null, primary key +# closed_at :datetime +# opened_at :datetime +# point_scale :integer default(10), not null +# position :string not null +# vacancies :integer default(1), not null +# created_at :datetime not null +# updated_at :datetime not null +# +require 'rails_helper' + +RSpec.describe Election, type: :model do + describe '#elected_users' do + subject { election.elected_users } + + context 'when there are vacancies for nominees' do + let(:election) { create(:election, vacancies: 3) } + let(:nomination_1) { create(:nomination, election: election) } + let(:nomination_2) { create(:nomination, election: election) } + + it 'returns all the nominated users' do + expect(subject).to contain_exactly( + nomination_1.nominee, + nomination_2.nominee, + ) + end + end + + context 'when there are more nominees than vacancies' do + let(:election) { create(:election, vacancies: 2, point_scale: 10) } + let(:nomination_1) { create(:nomination, election: election) } + let(:nomination_2) { create(:nomination, election: election) } + let(:nomination_3) { create(:nomination, election: election) } + let(:nomination_4) { create(:nomination, election: election) } + + before do + create(:vote, nomination: nomination_1, score: 3) + create(:vote, nomination: nomination_1, score: 1) + + create(:vote, nomination: nomination_2, score: 2) + create(:vote, nomination: nomination_2, score: 8) + create(:vote, nomination: nomination_2, score: 2) + + create(:vote, nomination: nomination_3, score: 4) + create(:vote, nomination: nomination_3, score: 10) + + create(:vote, nomination: nomination_4, score: 9) + create(:vote, nomination: nomination_4, score: 1) + create(:vote, nomination: nomination_4, score: 8) + end + + it 'returns the top scoring candidates required to fill the vacancies' do + expect(subject).to contain_exactly( + nomination_3.nominee, + nomination_4.nominee, + ) + end + end + end +end diff --git a/spec/models/nomination_spec.rb b/spec/models/nomination_spec.rb new file mode 100644 index 00000000..142cd5b7 --- /dev/null +++ b/spec/models/nomination_spec.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: nominations +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# election_id :bigint not null +# nominated_by_id :bigint not null +# nominee_id :bigint not null +# +# Indexes +# +# index_nominations_on_election_id (election_id) +# index_nominations_on_election_id_and_nominee_id (election_id,nominee_id) UNIQUE +# index_nominations_on_nominated_by_id (nominated_by_id) +# index_nominations_on_nominee_id (nominee_id) +# +# Foreign Keys +# +# fk_rails_... (election_id => elections.id) +# fk_rails_... (nominated_by_id => users.id) +# fk_rails_... (nominee_id => users.id) +# +require 'rails_helper' + +RSpec.describe Nomination, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/vote_spec.rb b/spec/models/vote_spec.rb new file mode 100644 index 00000000..996b2103 --- /dev/null +++ b/spec/models/vote_spec.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: votes +# +# id :bigint not null, primary key +# score :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# nomination_id :bigint not null +# voter_id :bigint not null +# +# Indexes +# +# index_votes_on_nomination_id (nomination_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (voter_id => users.id) +# +require 'rails_helper' + +RSpec.describe Vote, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end From 6dc4f86fc6881cf08ca0c529c36c2dbbda4d1317 Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Thu, 9 Oct 2025 12:24:36 +1100 Subject: [PATCH 2/6] Work-in-progress: validate elections and related models --- app/models/election.rb | 4 ++++ app/models/vote.rb | 10 ++++++++++ spec/factories/elections.rb | 14 ++++++++++++++ spec/factories/nominations.rb | 2 +- spec/models/election_spec.rb | 4 ++-- 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/models/election.rb b/app/models/election.rb index 58611731..395fed83 100644 --- a/app/models/election.rb +++ b/app/models/election.rb @@ -16,6 +16,10 @@ class Election < ApplicationRecord has_many :votes, through: :nominations has_many :nominated_candidates, through: :nominations, source: :nominee + def open? + opened_at.present? && opened_at <= Time.current && (closed_at.nil? || closed_at > Time.current) + end + def elected_users if nominated_candidates.count > vacancies top_scoring_candidates diff --git a/app/models/vote.rb b/app/models/vote.rb index d6aea23a..8cfa43c8 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -22,4 +22,14 @@ class Vote < ApplicationRecord belongs_to :nomination belongs_to :voter, class_name: 'User' + + validate :election_must_be_open_validation + + private + + def election_must_be_open_validation + if !nomination.election.open? + errors.add(:base, "Election is not currently open") + end + end end diff --git a/spec/factories/elections.rb b/spec/factories/elections.rb index 84613160..01a0d408 100644 --- a/spec/factories/elections.rb +++ b/spec/factories/elections.rb @@ -18,5 +18,19 @@ opened_at { nil } point_scale { 10 } vacancies { 1 } + + trait :open do + opened_at { 1.week.ago } + closed_at { 1.week.from_now } + end + + trait :closed do + opened_at { 1.month.ago } + closed_at { 1.day.ago } + end + + trait :pending do + opened_at { 1.week.from_now } + end end end diff --git a/spec/factories/nominations.rb b/spec/factories/nominations.rb index 180a80f9..f1cd6bd8 100644 --- a/spec/factories/nominations.rb +++ b/spec/factories/nominations.rb @@ -24,7 +24,7 @@ # FactoryBot.define do factory :nomination do - association :election + association :election, :open association :nominee, factory: :user association :nominated_by, factory: :user end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb index b26424d1..d60e6e05 100644 --- a/spec/models/election_spec.rb +++ b/spec/models/election_spec.rb @@ -18,7 +18,7 @@ subject { election.elected_users } context 'when there are vacancies for nominees' do - let(:election) { create(:election, vacancies: 3) } + let(:election) { create(:election, :open, vacancies: 3) } let(:nomination_1) { create(:nomination, election: election) } let(:nomination_2) { create(:nomination, election: election) } @@ -31,7 +31,7 @@ end context 'when there are more nominees than vacancies' do - let(:election) { create(:election, vacancies: 2, point_scale: 10) } + let(:election) { create(:election, :open, vacancies: 2, point_scale: 10) } let(:nomination_1) { create(:nomination, election: election) } let(:nomination_2) { create(:nomination, election: election) } let(:nomination_3) { create(:nomination, election: election) } From eca4e16d0d3e0e4d4ad689365293d0d3d8a10723 Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Wed, 19 Nov 2025 13:56:28 +1100 Subject: [PATCH 3/6] Fix rubocop offenses --- app/models/election.rb | 6 ++--- app/models/nomination.rb | 2 +- app/models/user.rb | 4 +-- app/models/vote.rb | 4 +-- spec/models/election_spec.rb | 48 ++++++++++++++++++------------------ 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/app/models/election.rb b/app/models/election.rb index 395fed83..c7affbbf 100644 --- a/app/models/election.rb +++ b/app/models/election.rb @@ -12,7 +12,7 @@ # updated_at :datetime not null # class Election < ApplicationRecord - has_many :nominations + has_many :nominations, dependent: :restrict_with_error has_many :votes, through: :nominations has_many :nominated_candidates, through: :nominations, source: :nominee @@ -31,8 +31,8 @@ def elected_users private def top_scoring_candidates - User.where(id: - nominations + User.where( + id: nominations .joins(:votes) .group('nominations.id') .order('SUM(votes.score) DESC') diff --git a/app/models/nomination.rb b/app/models/nomination.rb index cd04f807..530eb2f8 100644 --- a/app/models/nomination.rb +++ b/app/models/nomination.rb @@ -23,7 +23,7 @@ # fk_rails_... (nominee_id => users.id) # class Nomination < ApplicationRecord - has_many :votes + has_many :votes, dependent: :restrict_with_error belongs_to :election belongs_to :nominee, class_name: 'User' belongs_to :nominated_by, class_name: 'User' diff --git a/app/models/user.rb b/app/models/user.rb index 91e38bd9..73c7b6fa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -54,8 +54,8 @@ class User < ApplicationRecord has_many :memberships, dependent: :destroy has_many :emails, dependent: :destroy has_many :access_requests, foreign_key: :recorder_id, dependent: :destroy, inverse_of: :recorder - has_many :nominations_received, class_name: 'Nomination', foreign_key: :nominee_id, inverse_of: :nominee - has_many :nominations_made, class_name: 'Nomination', foreign_key: :nominated_by, inverse_of: :nominated_by + has_many :nominations_received, class_name: 'Nomination', foreign_key: :nominee_id, inverse_of: :nominee, dependent: :restrict_with_error + has_many :nominations_made, class_name: 'Nomination', foreign_key: :nominated_by, inverse_of: :nominated_by, dependent: :restrict_with_error scope :seeking_work, -> { where(seeking_work: true).where.not(linkedin_url: [nil, ""]) } scope :unconfirmed, -> { where(confirmed_at: nil) } diff --git a/app/models/vote.rb b/app/models/vote.rb index 8cfa43c8..75d5896e 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -28,8 +28,6 @@ class Vote < ApplicationRecord private def election_must_be_open_validation - if !nomination.election.open? - errors.add(:base, "Election is not currently open") - end + errors.add(:base, "Election is not currently open") unless nomination.election.open? end end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb index d60e6e05..771d2d32 100644 --- a/spec/models/election_spec.rb +++ b/spec/models/election_spec.rb @@ -15,48 +15,48 @@ RSpec.describe Election, type: :model do describe '#elected_users' do - subject { election.elected_users } + subject(:elected_users) { election.elected_users } - context 'when there are vacancies for nominees' do + context 'when there are sufficient vacancies for nominees' do let(:election) { create(:election, :open, vacancies: 3) } - let(:nomination_1) { create(:nomination, election: election) } - let(:nomination_2) { create(:nomination, election: election) } + let(:first_nomination) { create(:nomination, election: election) } + let(:second_nomination) { create(:nomination, election: election) } it 'returns all the nominated users' do - expect(subject).to contain_exactly( - nomination_1.nominee, - nomination_2.nominee, + expect(elected_users).to contain_exactly( + first_nomination.nominee, + second_nomination.nominee, ) end end context 'when there are more nominees than vacancies' do let(:election) { create(:election, :open, vacancies: 2, point_scale: 10) } - let(:nomination_1) { create(:nomination, election: election) } - let(:nomination_2) { create(:nomination, election: election) } - let(:nomination_3) { create(:nomination, election: election) } - let(:nomination_4) { create(:nomination, election: election) } + let(:nomination_amir) { create(:nomination, election: election) } + let(:nomination_billie) { create(:nomination, election: election) } + let(:nomination_carol) { create(:nomination, election: election) } + let(:nomination_denise) { create(:nomination, election: election) } before do - create(:vote, nomination: nomination_1, score: 3) - create(:vote, nomination: nomination_1, score: 1) + create(:vote, nomination: nomination_amir, score: 3) + create(:vote, nomination: nomination_amir, score: 1) - create(:vote, nomination: nomination_2, score: 2) - create(:vote, nomination: nomination_2, score: 8) - create(:vote, nomination: nomination_2, score: 2) + create(:vote, nomination: nomination_billie, score: 2) + create(:vote, nomination: nomination_billie, score: 8) + create(:vote, nomination: nomination_billie, score: 2) - create(:vote, nomination: nomination_3, score: 4) - create(:vote, nomination: nomination_3, score: 10) + create(:vote, nomination: nomination_carol, score: 4) + create(:vote, nomination: nomination_carol, score: 10) - create(:vote, nomination: nomination_4, score: 9) - create(:vote, nomination: nomination_4, score: 1) - create(:vote, nomination: nomination_4, score: 8) + create(:vote, nomination: nomination_denise, score: 9) + create(:vote, nomination: nomination_denise, score: 1) + create(:vote, nomination: nomination_denise, score: 8) end it 'returns the top scoring candidates required to fill the vacancies' do - expect(subject).to contain_exactly( - nomination_3.nominee, - nomination_4.nominee, + expect(elected_users).to contain_exactly( + nomination_carol.nominee, + nomination_denise.nominee, ) end end From f5ad005f148ab8521d86911e3c5a0d456108ed44 Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Mon, 24 Nov 2025 15:21:10 +1100 Subject: [PATCH 4/6] Votes belong to polymorphic votable association --- app/models/nomination.rb | 2 +- app/models/vote.rb | 23 +++++++++++----------- db/migrate/20251008023211_create_votes.rb | 4 ++-- db/schema.rb | 7 ++++--- spec/factories/votes.rb | 24 +++++++++++++---------- spec/models/election_spec.rb | 20 +++++++++---------- spec/models/vote_spec.rb | 19 +++++++++--------- 7 files changed, 53 insertions(+), 46 deletions(-) diff --git a/app/models/nomination.rb b/app/models/nomination.rb index 530eb2f8..928c9fc3 100644 --- a/app/models/nomination.rb +++ b/app/models/nomination.rb @@ -23,7 +23,7 @@ # fk_rails_... (nominee_id => users.id) # class Nomination < ApplicationRecord - has_many :votes, dependent: :restrict_with_error + has_many :votes, as: :votable, dependent: :restrict_with_error belongs_to :election belongs_to :nominee, class_name: 'User' belongs_to :nominated_by, class_name: 'User' diff --git a/app/models/vote.rb b/app/models/vote.rb index 75d5896e..4893481e 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -2,25 +2,26 @@ # # Table name: votes # -# id :bigint not null, primary key -# score :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# nomination_id :bigint not null -# voter_id :bigint not null +# id :bigint not null, primary key +# score :integer not null +# votable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# votable_id :bigint not null +# voter_id :bigint not null # # Indexes # -# index_votes_on_nomination_id (nomination_id) -# index_votes_on_voter_id (voter_id) -# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# index_votes_on_votable (votable_type,votable_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_votable_id (voter_id,votable_id) UNIQUE # # Foreign Keys # # fk_rails_... (voter_id => users.id) # class Vote < ApplicationRecord - belongs_to :nomination + belongs_to :votable, polymorphic: true belongs_to :voter, class_name: 'User' validate :election_must_be_open_validation @@ -28,6 +29,6 @@ class Vote < ApplicationRecord private def election_must_be_open_validation - errors.add(:base, "Election is not currently open") unless nomination.election.open? + errors.add(:base, "Election is not currently open") if votable.is_a?(Election) && !votable.election.open? end end diff --git a/db/migrate/20251008023211_create_votes.rb b/db/migrate/20251008023211_create_votes.rb index 1fafa188..64050019 100644 --- a/db/migrate/20251008023211_create_votes.rb +++ b/db/migrate/20251008023211_create_votes.rb @@ -3,11 +3,11 @@ def change create_table :votes do |t| t.integer :score, null: false t.references :voter, null: false, foreign_key: { to_table: :users } - t.references :nomination, null: false + t.references :votable, null: false, polymorphic: true t.timestamps end - add_index :votes, [:voter_id, :nomination_id], unique: true + add_index :votes, [:voter_id, :votable_id], unique: true end end diff --git a/db/schema.rb b/db/schema.rb index bce9d9a8..4099e698 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -392,12 +392,13 @@ create_table "votes", force: :cascade do |t| t.datetime "created_at", null: false - t.bigint "nomination_id", null: false t.integer "score", null: false t.datetime "updated_at", null: false + t.bigint "votable_id", null: false + t.string "votable_type", null: false t.bigint "voter_id", null: false - t.index ["nomination_id"], name: "index_votes_on_nomination_id" - t.index ["voter_id", "nomination_id"], name: "index_votes_on_voter_id_and_nomination_id", unique: true + t.index ["votable_type", "votable_id"], name: "index_votes_on_votable" + t.index ["voter_id", "votable_id"], name: "index_votes_on_voter_id_and_votable_id", unique: true t.index ["voter_id"], name: "index_votes_on_voter_id" end diff --git a/spec/factories/votes.rb b/spec/factories/votes.rb index 49373eac..61cdb4c7 100644 --- a/spec/factories/votes.rb +++ b/spec/factories/votes.rb @@ -2,18 +2,19 @@ # # Table name: votes # -# id :bigint not null, primary key -# score :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# nomination_id :bigint not null -# voter_id :bigint not null +# id :bigint not null, primary key +# score :integer not null +# votable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# votable_id :bigint not null +# voter_id :bigint not null # # Indexes # -# index_votes_on_nomination_id (nomination_id) -# index_votes_on_voter_id (voter_id) -# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# index_votes_on_votable (votable_type,votable_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_votable_id (voter_id,votable_id) UNIQUE # # Foreign Keys # @@ -22,7 +23,10 @@ FactoryBot.define do factory :vote do score { 5 } - association :nomination association :voter, factory: :user + + trait :for_nomination do + association :votable, factory: :nomination + end end end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb index 771d2d32..c2691a56 100644 --- a/spec/models/election_spec.rb +++ b/spec/models/election_spec.rb @@ -38,19 +38,19 @@ let(:nomination_denise) { create(:nomination, election: election) } before do - create(:vote, nomination: nomination_amir, score: 3) - create(:vote, nomination: nomination_amir, score: 1) + create(:vote, :for_nomination, votable: nomination_amir, score: 3) + create(:vote, :for_nomination, votable: nomination_amir, score: 1) - create(:vote, nomination: nomination_billie, score: 2) - create(:vote, nomination: nomination_billie, score: 8) - create(:vote, nomination: nomination_billie, score: 2) + create(:vote, :for_nomination, votable: nomination_billie, score: 2) + create(:vote, :for_nomination, votable: nomination_billie, score: 8) + create(:vote, :for_nomination, votable: nomination_billie, score: 2) - create(:vote, nomination: nomination_carol, score: 4) - create(:vote, nomination: nomination_carol, score: 10) + create(:vote, :for_nomination, votable: nomination_carol, score: 4) + create(:vote, :for_nomination, votable: nomination_carol, score: 10) - create(:vote, nomination: nomination_denise, score: 9) - create(:vote, nomination: nomination_denise, score: 1) - create(:vote, nomination: nomination_denise, score: 8) + create(:vote, :for_nomination, votable: nomination_denise, score: 9) + create(:vote, :for_nomination, votable: nomination_denise, score: 1) + create(:vote, :for_nomination, votable: nomination_denise, score: 8) end it 'returns the top scoring candidates required to fill the vacancies' do diff --git a/spec/models/vote_spec.rb b/spec/models/vote_spec.rb index 996b2103..52e4bebd 100644 --- a/spec/models/vote_spec.rb +++ b/spec/models/vote_spec.rb @@ -2,18 +2,19 @@ # # Table name: votes # -# id :bigint not null, primary key -# score :integer not null -# created_at :datetime not null -# updated_at :datetime not null -# nomination_id :bigint not null -# voter_id :bigint not null +# id :bigint not null, primary key +# score :integer not null +# votable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# votable_id :bigint not null +# voter_id :bigint not null # # Indexes # -# index_votes_on_nomination_id (nomination_id) -# index_votes_on_voter_id (voter_id) -# index_votes_on_voter_id_and_nomination_id (voter_id,nomination_id) UNIQUE +# index_votes_on_votable (votable_type,votable_id) +# index_votes_on_voter_id (voter_id) +# index_votes_on_voter_id_and_votable_id (voter_id,votable_id) UNIQUE # # Foreign Keys # From 0f45b4101dca184f95ead16c4b96f6d1f3d97d11 Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Tue, 25 Nov 2025 01:37:10 +1100 Subject: [PATCH 5/6] [wip] --- Gemfile | 1 + Gemfile.lock | 3 + app/controllers/admin/elections_controller.rb | 52 +++++++ .../admin/nominations_controller.rb | 56 ++++++++ app/controllers/elections_controller.rb | 70 ++++++++++ app/helpers/elections_helper.rb | 2 + app/models/election.rb | 18 ++- app/views/admin/elections/_election.html.erb | 89 ++++++++++++ app/views/admin/elections/_form.html.erb | 58 ++++++++ app/views/admin/elections/edit.html.erb | 22 +++ app/views/admin/elections/index.html.erb | 66 +++++++++ app/views/admin/elections/new.html.erb | 20 +++ app/views/admin/elections/show.html.erb | 19 +++ app/views/admin/nominations/_form.html.erb | 39 ++++++ app/views/admin/nominations/new.html.erb | 20 +++ app/views/elections/_election.html.erb | 1 + app/views/elections/_election.json.jbuilder | 2 + app/views/elections/_form.html.erb | 17 +++ app/views/elections/edit.html.erb | 12 ++ app/views/elections/index.html.erb | 17 +++ app/views/elections/index.json.jbuilder | 1 + app/views/elections/new.html.erb | 11 ++ app/views/elections/show.html.erb | 7 + app/views/elections/show.json.jbuilder | 1 + app/views/shared/_navigation.html.erb | 1 + config/routes.rb | 4 + db/migrate/20251007055656_create_elections.rb | 2 +- db/schema.rb | 2 +- spec/factories/elections.rb | 4 +- spec/helpers/elections_helper_spec.rb | 15 ++ spec/models/election_spec.rb | 2 +- spec/requests/elections_spec.rb | 131 ++++++++++++++++++ spec/routing/elections_routing_spec.rb | 38 +++++ spec/views/elections/edit.html.erb_spec.rb | 18 +++ spec/views/elections/index.html.erb_spec.rb | 15 ++ spec/views/elections/new.html.erb_spec.rb | 14 ++ spec/views/elections/show.html.erb_spec.rb | 11 ++ 37 files changed, 853 insertions(+), 8 deletions(-) create mode 100644 app/controllers/admin/elections_controller.rb create mode 100644 app/controllers/admin/nominations_controller.rb create mode 100644 app/controllers/elections_controller.rb create mode 100644 app/helpers/elections_helper.rb create mode 100644 app/views/admin/elections/_election.html.erb create mode 100644 app/views/admin/elections/_form.html.erb create mode 100644 app/views/admin/elections/edit.html.erb create mode 100644 app/views/admin/elections/index.html.erb create mode 100644 app/views/admin/elections/new.html.erb create mode 100644 app/views/admin/elections/show.html.erb create mode 100644 app/views/admin/nominations/_form.html.erb create mode 100644 app/views/admin/nominations/new.html.erb create mode 100644 app/views/elections/_election.html.erb create mode 100644 app/views/elections/_election.json.jbuilder create mode 100644 app/views/elections/_form.html.erb create mode 100644 app/views/elections/edit.html.erb create mode 100644 app/views/elections/index.html.erb create mode 100644 app/views/elections/index.json.jbuilder create mode 100644 app/views/elections/new.html.erb create mode 100644 app/views/elections/show.html.erb create mode 100644 app/views/elections/show.json.jbuilder create mode 100644 spec/helpers/elections_helper_spec.rb create mode 100644 spec/requests/elections_spec.rb create mode 100644 spec/routing/elections_routing_spec.rb create mode 100644 spec/views/elections/edit.html.erb_spec.rb create mode 100644 spec/views/elections/index.html.erb_spec.rb create mode 100644 spec/views/elections/new.html.erb_spec.rb create mode 100644 spec/views/elections/show.html.erb_spec.rb diff --git a/Gemfile b/Gemfile index d2718c3d..00d6683c 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,7 @@ group :development, :test do gem 'rubocop-performance', require: false gem 'rubocop-rspec_rails' gem 'rubocop-rails', require: false + gem 'pry-rails' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 17339f99..0798a4b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -344,6 +344,8 @@ GEM pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) + pry-rails (0.3.11) + pry (>= 0.13.0) psych (5.3.0) date stringio @@ -615,6 +617,7 @@ DEPENDENCIES meta-tags (~> 2.22) pg premailer-rails + pry-rails puma pygmentize rails (>= 8.0.0) diff --git a/app/controllers/admin/elections_controller.rb b/app/controllers/admin/elections_controller.rb new file mode 100644 index 00000000..c095dd0f --- /dev/null +++ b/app/controllers/admin/elections_controller.rb @@ -0,0 +1,52 @@ +class Admin::ElectionsController < Admin::ApplicationController + before_action :set_election, only: %i[show edit update destroy] + before_action :set_election_nominations, only: %i[show] + + def index + @elections = Election.order(created_at: :desc) + end + + def show; end + + def new + @election = Election.new + end + + def edit; end + + def create + @election = Election.new(election_params) + + if @election.save + redirect_to admin_election_path(@election), notice: "Election was successfully created." + else + render :new, status: :unprocessable_content + end + end + + def update + if @election.update(election_params) + redirect_to admin_election_path(@election), notice: "Election was successfully updated." + else + render :edit, status: :unprocessable_content, alert: "Election could not be updated due to #{@election.errors.full_messages.to_sentence}." + end + end + + def destroy + raise NotImplementedError + end + + private + + def set_election + @election = Election.find(params[:id]) + end + + def set_election_nominations + @nominations = @election.nominations + end + + def election_params + params.expect(election: [:closed_at, :opened_at, :point_scale, :title, :vacancies]) + end +end diff --git a/app/controllers/admin/nominations_controller.rb b/app/controllers/admin/nominations_controller.rb new file mode 100644 index 00000000..cdb9becf --- /dev/null +++ b/app/controllers/admin/nominations_controller.rb @@ -0,0 +1,56 @@ +class Admin::NominationsController < Admin::ApplicationController + before_action :set_election, only: %i[new create] + + def new + @nomination = Nomination.new + end + + def create + p params + nomination_attributes = build_nomination_attributes + + if nomination_attributes[:errors].present? + @nomination = Nomination.new + @nomination.errors.add(:base, nomination_attributes[:errors]) + render :new, status: :unprocessable_entity + else + @nomination = Nomination.new(nomination_attributes.merge(election: @election)) + + if @nomination.save + redirect_to admin_election_path(@election), notice: "Nomination was successfully created." + else + render :new, status: :unprocessable_content + end + end + end + + def destroy + raise NotImplementedError + end + + private + + def set_election + @election = Election.find(params[:election_id]) + end + + def build_nomination_attributes + nominated_member = Email.find_by(email: nomination_params[:member_email_address])&.user + nominating_member = Email.find_by(email: nomination_params[:nominating_member_email_address])&.user + + errors = [] + errors << "Member not found" unless nominated_member + errors << "Nominating member not found" unless nominating_member + + return { errors: errors.join(", ") } if errors.any? + + { + nominee_id: nominated_member.id, + nominated_by_id: nominating_member.id + } + end + + def nomination_params + params.expect(nomination: [:member_email_address, :nominating_member_email_address]) + end +end diff --git a/app/controllers/elections_controller.rb b/app/controllers/elections_controller.rb new file mode 100644 index 00000000..b04b2d79 --- /dev/null +++ b/app/controllers/elections_controller.rb @@ -0,0 +1,70 @@ +class ElectionsController < ApplicationController + before_action :set_election, only: %i[ show edit update destroy ] + + # GET /elections or /elections.json + def index + @elections = Election.all + end + + # GET /elections/1 or /elections/1.json + def show + end + + # GET /elections/new + def new + @election = Election.new + end + + # GET /elections/1/edit + def edit + end + + # POST /elections or /elections.json + def create + @election = Election.new(election_params) + + respond_to do |format| + if @election.save + format.html { redirect_to @election, notice: "Election was successfully created." } + format.json { render :show, status: :created, location: @election } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @election.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /elections/1 or /elections/1.json + def update + respond_to do |format| + if @election.update(election_params) + format.html { redirect_to @election, notice: "Election was successfully updated.", status: :see_other } + format.json { render :show, status: :ok, location: @election } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @election.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /elections/1 or /elections/1.json + def destroy + @election.destroy! + + respond_to do |format| + format.html { redirect_to elections_path, notice: "Election was successfully destroyed.", status: :see_other } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_election + @election = Election.find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def election_params + params.fetch(:election, {}) + end +end diff --git a/app/helpers/elections_helper.rb b/app/helpers/elections_helper.rb new file mode 100644 index 00000000..09497b1d --- /dev/null +++ b/app/helpers/elections_helper.rb @@ -0,0 +1,2 @@ +module ElectionsHelper +end diff --git a/app/models/election.rb b/app/models/election.rb index c7affbbf..7c0bc6a1 100644 --- a/app/models/election.rb +++ b/app/models/election.rb @@ -6,7 +6,7 @@ # closed_at :datetime # opened_at :datetime # point_scale :integer default(10), not null -# position :string not null +# title :string not null # vacancies :integer default(1), not null # created_at :datetime not null # updated_at :datetime not null @@ -16,12 +16,20 @@ class Election < ApplicationRecord has_many :votes, through: :nominations has_many :nominated_candidates, through: :nominations, source: :nominee + scope :open, -> { where('opened_at <= ?', Time.current).where('closed_at > ? OR closed_at IS NULL', Time.current) } + + validates_presence_of :title, :point_scale, :vacancies + def open? - opened_at.present? && opened_at <= Time.current && (closed_at.nil? || closed_at > Time.current) + opened_at?.past? && !closed? + end + + def closed? + closed_at?.past? end def elected_users - if nominated_candidates.count > vacancies + if voting_required? top_scoring_candidates else nominated_candidates @@ -30,6 +38,10 @@ def elected_users private + def voting_required? + nominated_candidates.count > vacancies + end + def top_scoring_candidates User.where( id: nominations diff --git a/app/views/admin/elections/_election.html.erb b/app/views/admin/elections/_election.html.erb new file mode 100644 index 00000000..8119d5be --- /dev/null +++ b/app/views/admin/elections/_election.html.erb @@ -0,0 +1,89 @@ +
+
+
+
+

Election

+
+ +
+
+

Title

+

<%= election.title %>

+
+ +
+
+

Vacancies

+

<%= election.vacancies %>

+
+ +
+

Point scale

+

<%= election.point_scale %>

+
+
+ +
+
+

Opened at

+

<%= election.opened_at %>

+
+ +
+

Closed at

+

<%= election.closed_at %>

+
+
+ +
+

Created at

+

<%= election.created_at %>

+
+
+
+
+ +
+
+ <% if @nominations.none? %> +
+

No nominations.

+
+ <% else %> +
+

Nominations

+
+ +
+ + + + + + + + + + + <% @nominations.each do |nomination| %> + + + + + + + <% end %> + +
NameEmailNominated by
<%= nomination.nominee.full_name %><%= nomination.nominee.email %><%= nomination.nominated_by.email %>
+ + <%#
    %> + <%# <% @nominations.each do |nomination| %1> %> + <%#
  • <%= nomination.nominee.email %1>
  • %> + <%# <% end %1> %> + <%#
%> + +
+ <% end %> +
+
+
diff --git a/app/views/admin/elections/_form.html.erb b/app/views/admin/elections/_form.html.erb new file mode 100644 index 00000000..b67d6082 --- /dev/null +++ b/app/views/admin/elections/_form.html.erb @@ -0,0 +1,58 @@ +<%= form_with(model: [:admin, election], class: "w-full mx-auto") do |form| %> + <% if election.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(election.errors.count, "error") %> prohibited this election from being saved: +

+
    + <% election.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= form.label :title, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :title, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+
+ <%= form.label :vacancies, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.number_field :vacancies, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+ <%= form.label :point_scale, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.number_field :point_scale, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+
+ +
+
+ <%= form.label :opened_at, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.datetime_field :opened_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+ +
+ <%= form.label :closed_at, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.datetime_field :closed_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-ruby-red focus:ring focus:ring-ruby-red/20 text-sm sm:text-base" %> +
+
+ +
+ <%= form.submit class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %> +
+
+<% end %> diff --git a/app/views/admin/elections/edit.html.erb b/app/views/admin/elections/edit.html.erb new file mode 100644 index 00000000..51deb80c --- /dev/null +++ b/app/views/admin/elections/edit.html.erb @@ -0,0 +1,22 @@ +
+
+
+
+

Edit Election

+
+ +
+ <%= render "form", election: @election %> +
+ +
+ <%= link_to admin_election_path(@election), class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to election + <% end %> +
+
+
+
diff --git a/app/views/admin/elections/index.html.erb b/app/views/admin/elections/index.html.erb new file mode 100644 index 00000000..c33e124c --- /dev/null +++ b/app/views/admin/elections/index.html.erb @@ -0,0 +1,66 @@ +
+
+

Elections

+ + <%= link_to new_admin_election_path, class: "inline-flex items-center justify-center bg-ruby-red hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors shadow-sm text-sm sm:text-base" do %> + + + + New Election + <% end %> +
+ + <% if @elections.none? %> +
+

No elections.

+
+ <% else %> +
+
+ + + + + + + + + + + + + + <% @elections.each do |election| %> + + + + + + + + + + <% end %> + +
Point scaleTitleVacancies
<%= election.point_scale %> + <%= link_to admin_election_path(election), class: "text-ruby-red hover:text-red-700 transition-colors" do %> + <%= election.title %> + <% end %> + <%= election.vacancies %>
+
+ + <% if @posts.respond_to?(:total_pages) && @posts.total_pages > 1 %> +
+
+
+ Showing page <%= @posts.current_page %> of <%= @posts.total_pages %> +
+
+ <%= paginate @posts %> +
+
+
+ <% end %> + <% end %> +
+
diff --git a/app/views/admin/elections/new.html.erb b/app/views/admin/elections/new.html.erb new file mode 100644 index 00000000..f0867316 --- /dev/null +++ b/app/views/admin/elections/new.html.erb @@ -0,0 +1,20 @@ +
+
+
+

New Election

+
+ +
+ <%= render "form", election: @election %> + +
+ <%= link_to admin_elections_path, class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to elections + <% end %> +
+
+
+
diff --git a/app/views/admin/elections/show.html.erb b/app/views/admin/elections/show.html.erb new file mode 100644 index 00000000..cd70e53d --- /dev/null +++ b/app/views/admin/elections/show.html.erb @@ -0,0 +1,19 @@ +
+ <%= render @election %> + +
+
+ <%= link_to "Edit", edit_admin_election_path(@election), class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + + <%= link_to "Delete", admin_election_path(@election), + method: :delete, + data: { confirm: 'Are you sure you want to delete this election?' }, + class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + + <%= link_to "Add nomination", new_admin_election_nomination_path(@election), class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> + + <%= link_to "Back to elections", admin_elections_path, + class: "inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-600 border border-gray-300 rounded-md bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500" %> +
+
+
diff --git a/app/views/admin/nominations/_form.html.erb b/app/views/admin/nominations/_form.html.erb new file mode 100644 index 00000000..eabe240a --- /dev/null +++ b/app/views/admin/nominations/_form.html.erb @@ -0,0 +1,39 @@ +<%= form_with(model: [:admin, :election, nomination], class: "w-full mx-auto") do |form| %> + <% if nomination.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(nomination.errors.count, "error") %> prohibited this nomination from being saved: +

+
    + <% nomination.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= form.label :member_email_address, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :member_email_address, class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-ruby-red focus:border-ruby-red sm:text-sm" %> +
+ +
+ <%= form.label :nominating_member_email_address, class: "block text-sm sm:text-base font-medium text-gray-700" %> + <%= form.text_field :nominating_member_email_address, class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-ruby-red focus:border-ruby-red sm:text-sm" %> +
+ +
+ <%= form.submit class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %> +
+
+<% end %> diff --git a/app/views/admin/nominations/new.html.erb b/app/views/admin/nominations/new.html.erb new file mode 100644 index 00000000..1d4d7a42 --- /dev/null +++ b/app/views/admin/nominations/new.html.erb @@ -0,0 +1,20 @@ +
+
+
+

New Nomination

+
+ +
+ <%= render "form", nomination: @nomination %> + +
+ <%= link_to admin_election_path(@election), class: "group inline-flex items-center justify-center text-gray-700 hover:text-ruby-red transition-colors text-sm sm:text-base" do %> + + + + Back to election + <% end %> +
+
+
+
diff --git a/app/views/elections/_election.html.erb b/app/views/elections/_election.html.erb new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/app/views/elections/_election.html.erb @@ -0,0 +1 @@ + diff --git a/app/views/elections/_election.json.jbuilder b/app/views/elections/_election.json.jbuilder new file mode 100644 index 00000000..8af84ab4 --- /dev/null +++ b/app/views/elections/_election.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! election, :id, :created_at, :updated_at +json.url election_url(election, format: :json) diff --git a/app/views/elections/_form.html.erb b/app/views/elections/_form.html.erb new file mode 100644 index 00000000..9bc277d7 --- /dev/null +++ b/app/views/elections/_form.html.erb @@ -0,0 +1,17 @@ +<%= form_with(model: election) do |form| %> + <% if election.errors.any? %> +
+

<%= pluralize(election.errors.count, "error") %> prohibited this election from being saved:

+ +
    + <% election.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/elections/edit.html.erb b/app/views/elections/edit.html.erb new file mode 100644 index 00000000..e43c0542 --- /dev/null +++ b/app/views/elections/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing election" %> + +

Editing election

+ +<%= render "form", election: @election %> + +
+ +
+ <%= link_to "Show this election", @election %> | + <%= link_to "Back to elections", elections_path %> +
diff --git a/app/views/elections/index.html.erb b/app/views/elections/index.html.erb new file mode 100644 index 00000000..63984e2f --- /dev/null +++ b/app/views/elections/index.html.erb @@ -0,0 +1,17 @@ +
+
+

Elections

+
+
+ +
+ <% if @elections.any? %> + <% @elections.each do |election| %> + <%= link_to "Election: #{election.title}", election %> + <% end %> + <% else %> +
+

No elections.

+
+ <% end %> +
diff --git a/app/views/elections/index.json.jbuilder b/app/views/elections/index.json.jbuilder new file mode 100644 index 00000000..75079f9f --- /dev/null +++ b/app/views/elections/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @elections, partial: "elections/election", as: :election diff --git a/app/views/elections/new.html.erb b/app/views/elections/new.html.erb new file mode 100644 index 00000000..b38d39bc --- /dev/null +++ b/app/views/elections/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New election" %> + +

New election

+ +<%= render "form", election: @election %> + +
+ +
+ <%= link_to "Back to elections", elections_path %> +
diff --git a/app/views/elections/show.html.erb b/app/views/elections/show.html.erb new file mode 100644 index 00000000..5f726224 --- /dev/null +++ b/app/views/elections/show.html.erb @@ -0,0 +1,7 @@ +

<%= notice %>

+ +<%= render @election %> + +
+ <%= link_to "Back to elections", elections_path %> +
diff --git a/app/views/elections/show.json.jbuilder b/app/views/elections/show.json.jbuilder new file mode 100644 index 00000000..c13d2b63 --- /dev/null +++ b/app/views/elections/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "elections/election", election: @election diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb index 9cdddc94..bc524407 100644 --- a/app/views/shared/_navigation.html.erb +++ b/app/views/shared/_navigation.html.erb @@ -86,6 +86,7 @@ <%= link_to "Members", admin_memberships_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <%= link_to "Access Requests", admin_access_requests_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <%= link_to "Imported Members", admin_imported_members_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> + <%= link_to "Elections", admin_elections_path, class: "nav-link text-sm sm:text-base block px-4 py-2 hover:bg-white/10" %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 6ddae4e7..1448d501 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + resources :elections, only: [:show, :index] constraints subdomain: "melbourne" do mount(Melbourne::Engine, at: "/") end @@ -56,6 +57,9 @@ get 'posts/*slug', to: 'posts#show', as: :post patch 'posts/*slug', to: 'posts#update', as: :update_post resources :analytics, only: [:index] + resources :elections, only: [:index, :show, :new, :create, :edit, :update, :destroy] do + resources :nominations, only: [:new, :create, :destroy] + end end namespace :api do diff --git a/db/migrate/20251007055656_create_elections.rb b/db/migrate/20251007055656_create_elections.rb index f4ce5fdd..2edce65d 100644 --- a/db/migrate/20251007055656_create_elections.rb +++ b/db/migrate/20251007055656_create_elections.rb @@ -1,7 +1,7 @@ class CreateElections < ActiveRecord::Migration[8.0] def change create_table :elections do |t| - t.string :position, null: false + t.string :title, null: false t.integer :vacancies, default: 1, null: false t.integer :point_scale, default: 10, null: false t.datetime :opened_at diff --git a/db/schema.rb b/db/schema.rb index 4099e698..a31383c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -132,7 +132,7 @@ t.datetime "created_at", null: false t.datetime "opened_at" t.integer "point_scale", default: 10, null: false - t.string "position", null: false + t.string "title", null: false t.datetime "updated_at", null: false t.integer "vacancies", default: 1, null: false end diff --git a/spec/factories/elections.rb b/spec/factories/elections.rb index 01a0d408..66a3ae25 100644 --- a/spec/factories/elections.rb +++ b/spec/factories/elections.rb @@ -6,14 +6,14 @@ # closed_at :datetime # opened_at :datetime # point_scale :integer default(10), not null -# position :string not null +# title :string not null # vacancies :integer default(1), not null # created_at :datetime not null # updated_at :datetime not null # FactoryBot.define do factory :election do - position { 'President' } + title { 'President' } closed_at { nil } opened_at { nil } point_scale { 10 } diff --git a/spec/helpers/elections_helper_spec.rb b/spec/helpers/elections_helper_spec.rb new file mode 100644 index 00000000..c50ba7e7 --- /dev/null +++ b/spec/helpers/elections_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the ElectionsHelper. For example: +# +# describe ElectionsHelper 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 ElectionsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/election_spec.rb b/spec/models/election_spec.rb index c2691a56..9156e684 100644 --- a/spec/models/election_spec.rb +++ b/spec/models/election_spec.rb @@ -6,7 +6,7 @@ # closed_at :datetime # opened_at :datetime # point_scale :integer default(10), not null -# position :string not null +# title :string not null # vacancies :integer default(1), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/spec/requests/elections_spec.rb b/spec/requests/elections_spec.rb new file mode 100644 index 00000000..b95938e0 --- /dev/null +++ b/spec/requests/elections_spec.rb @@ -0,0 +1,131 @@ +require 'rails_helper' + +# This spec was generated by rspec-rails when you ran the scaffold generator. +# It demonstrates how one might use RSpec to test the controller code that +# was generated by Rails when you ran the scaffold generator. +# +# It assumes that the implementation code is generated by the rails scaffold +# generator. If you are using any extension libraries to generate different +# controller code, this generated spec may or may not pass. +# +# It only uses APIs available in rails and/or rspec-rails. There are a number +# of tools you can use to make these specs even more expressive, but we're +# sticking to rails and rspec-rails APIs to keep things simple and stable. + +RSpec.describe "/elections", type: :request do + + # This should return the minimal set of attributes required to create a valid + # Election. As you add validations to Election, be sure to + # adjust the attributes here as well. + let(:valid_attributes) { + skip("Add a hash of attributes valid for your model") + } + + let(:invalid_attributes) { + skip("Add a hash of attributes invalid for your model") + } + + describe "GET /index" do + it "renders a successful response" do + Election.create! valid_attributes + get elections_url + expect(response).to be_successful + end + end + + describe "GET /show" do + it "renders a successful response" do + election = Election.create! valid_attributes + get election_url(election) + expect(response).to be_successful + end + end + + describe "GET /new" do + it "renders a successful response" do + get new_election_url + expect(response).to be_successful + end + end + + describe "GET /edit" do + it "renders a successful response" do + election = Election.create! valid_attributes + get edit_election_url(election) + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new Election" do + expect { + post elections_url, params: { election: valid_attributes } + }.to change(Election, :count).by(1) + end + + it "redirects to the created election" do + post elections_url, params: { election: valid_attributes } + expect(response).to redirect_to(election_url(Election.last)) + end + end + + context "with invalid parameters" do + it "does not create a new Election" do + expect { + post elections_url, params: { election: invalid_attributes } + }.to change(Election, :count).by(0) + end + + it "renders a response with 422 status (i.e. to display the 'new' template)" do + post elections_url, params: { election: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:new_attributes) { + skip("Add a hash of attributes valid for your model") + } + + it "updates the requested election" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: new_attributes } + election.reload + skip("Add assertions for updated state") + end + + it "redirects to the election" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: new_attributes } + election.reload + expect(response).to redirect_to(election_url(election)) + end + end + + context "with invalid parameters" do + it "renders a response with 422 status (i.e. to display the 'edit' template)" do + election = Election.create! valid_attributes + patch election_url(election), params: { election: invalid_attributes } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "DELETE /destroy" do + it "destroys the requested election" do + election = Election.create! valid_attributes + expect { + delete election_url(election) + }.to change(Election, :count).by(-1) + end + + it "redirects to the elections list" do + election = Election.create! valid_attributes + delete election_url(election) + expect(response).to redirect_to(elections_url) + end + end +end diff --git a/spec/routing/elections_routing_spec.rb b/spec/routing/elections_routing_spec.rb new file mode 100644 index 00000000..902eb4ca --- /dev/null +++ b/spec/routing/elections_routing_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe ElectionsController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/elections").to route_to("elections#index") + end + + it "routes to #new" do + expect(get: "/elections/new").to route_to("elections#new") + end + + it "routes to #show" do + expect(get: "/elections/1").to route_to("elections#show", id: "1") + end + + it "routes to #edit" do + expect(get: "/elections/1/edit").to route_to("elections#edit", id: "1") + end + + + it "routes to #create" do + expect(post: "/elections").to route_to("elections#create") + end + + it "routes to #update via PUT" do + expect(put: "/elections/1").to route_to("elections#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/elections/1").to route_to("elections#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/elections/1").to route_to("elections#destroy", id: "1") + end + end +end diff --git a/spec/views/elections/edit.html.erb_spec.rb b/spec/views/elections/edit.html.erb_spec.rb new file mode 100644 index 00000000..5a09ce6f --- /dev/null +++ b/spec/views/elections/edit.html.erb_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe "elections/edit", type: :view do + let(:election) { + Election.create!() + } + + before(:each) do + assign(:election, election) + end + + it "renders the edit election form" do + render + + assert_select "form[action=?][method=?]", election_path(election), "post" do + end + end +end diff --git a/spec/views/elections/index.html.erb_spec.rb b/spec/views/elections/index.html.erb_spec.rb new file mode 100644 index 00000000..a95de164 --- /dev/null +++ b/spec/views/elections/index.html.erb_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe "elections/index", type: :view do + before(:each) do + assign(:elections, [ + Election.create!(), + Election.create!() + ]) + end + + it "renders a list of elections" do + render + cell_selector = 'div>p' + end +end diff --git a/spec/views/elections/new.html.erb_spec.rb b/spec/views/elections/new.html.erb_spec.rb new file mode 100644 index 00000000..05294231 --- /dev/null +++ b/spec/views/elections/new.html.erb_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe "elections/new", type: :view do + before(:each) do + assign(:election, Election.new()) + end + + it "renders new election form" do + render + + assert_select "form[action=?][method=?]", elections_path, "post" do + end + end +end diff --git a/spec/views/elections/show.html.erb_spec.rb b/spec/views/elections/show.html.erb_spec.rb new file mode 100644 index 00000000..e3bfca73 --- /dev/null +++ b/spec/views/elections/show.html.erb_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe "elections/show", type: :view do + before(:each) do + assign(:election, Election.create!()) + end + + it "renders attributes in

" do + render + end +end From 5bd9c8a6eebd313b04dbdb9e51780f0de8647d3b Mon Sep 17 00:00:00 2001 From: Timothy Reen Date: Thu, 8 Jan 2026 01:47:12 +1100 Subject: [PATCH 6/6] [wip] --- app/controllers/ballots_controller.rb | 19 +++++++ app/controllers/elections_controller.rb | 21 ++++--- app/models/ballot.rb | 3 + app/models/election.rb | 5 +- app/models/vote.rb | 2 +- app/views/elections/_election.html.erb | 19 ++++++- app/views/elections/_form.html.erb | 56 +++++++++++++++---- app/views/elections/index.html.erb | 49 ++++++++++++---- app/views/elections/show.html.erb | 17 ++++-- app/views/elections/show.json.jbuilder | 1 - config/routes.rb | 4 +- db/migrate/20251007055656_create_elections.rb | 3 +- 12 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 app/controllers/ballots_controller.rb create mode 100644 app/models/ballot.rb delete mode 100644 app/views/elections/show.json.jbuilder diff --git a/app/controllers/ballots_controller.rb b/app/controllers/ballots_controller.rb new file mode 100644 index 00000000..e384c31d --- /dev/null +++ b/app/controllers/ballots_controller.rb @@ -0,0 +1,19 @@ +class BallotsController < ApplicationController + def create + ballot_params.each do |nomination_id, score| + nomination = Nomination.find(nomination_id) + + Vote.create!( + voter: current_user, + votable: nomination, + score: score, + ) + end + end + + private + + def ballot_params + params.require(:ballot).require(:nomination_id_scores).permit! + end +end diff --git a/app/controllers/elections_controller.rb b/app/controllers/elections_controller.rb index b04b2d79..e4b5f1df 100644 --- a/app/controllers/elections_controller.rb +++ b/app/controllers/elections_controller.rb @@ -3,11 +3,13 @@ class ElectionsController < ApplicationController # GET /elections or /elections.json def index - @elections = Election.all + @current_elections = Election.open + @closed_elections = Election.closed end # GET /elections/1 or /elections/1.json def show + @ballot = Ballot.new end # GET /elections/new @@ -58,13 +60,14 @@ def destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_election - @election = Election.find(params.expect(:id)) - end - # Only allow a list of trusted parameters through. - def election_params - params.fetch(:election, {}) - end + # Use callbacks to share common setup or constraints between actions. + def set_election + @election = Election.includes(nominations: :nominee).find(params.expect(:id)) + end + + # Only allow a list of trusted parameters through. + def election_params + params.fetch(:election, {}) + end end diff --git a/app/models/ballot.rb b/app/models/ballot.rb new file mode 100644 index 00000000..2b9a40fa --- /dev/null +++ b/app/models/ballot.rb @@ -0,0 +1,3 @@ +class Ballot + include ActiveModel::Model +end diff --git a/app/models/election.rb b/app/models/election.rb index 7c0bc6a1..f864a2c8 100644 --- a/app/models/election.rb +++ b/app/models/election.rb @@ -17,15 +17,16 @@ class Election < ApplicationRecord has_many :nominated_candidates, through: :nominations, source: :nominee scope :open, -> { where('opened_at <= ?', Time.current).where('closed_at > ? OR closed_at IS NULL', Time.current) } + scope :closed, -> { where('closed_at <= ?', Time.current) } validates_presence_of :title, :point_scale, :vacancies def open? - opened_at?.past? && !closed? + opened_at.past? && !closed? end def closed? - closed_at?.past? + closed_at.past? end def elected_users diff --git a/app/models/vote.rb b/app/models/vote.rb index 4893481e..2b238e7c 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -29,6 +29,6 @@ class Vote < ApplicationRecord private def election_must_be_open_validation - errors.add(:base, "Election is not currently open") if votable.is_a?(Election) && !votable.election.open? + errors.add(:base, "Election is not currently open") if votable.is_a?(Nomination) && !votable.election.open? end end diff --git a/app/views/elections/_election.html.erb b/app/views/elections/_election.html.erb index 8b137891..d929f25f 100644 --- a/app/views/elections/_election.html.erb +++ b/app/views/elections/_election.html.erb @@ -1 +1,18 @@ - +

+
+

+ Election for <%= election.title %> +

+
+ <% if election.open? %> + Closes in <%= time_ago_in_words(election.closed_at) %> + <% else %> + Closed at <%= I18n.l(election.closed_at) %> + <% end %> +
+
+
+

Give each nominee a score between 0 and <%= election.point_scale %>.

+
+ <%= render "form", election: election, ballot: ballot %> +
diff --git a/app/views/elections/_form.html.erb b/app/views/elections/_form.html.erb index 9bc277d7..5fd50675 100644 --- a/app/views/elections/_form.html.erb +++ b/app/views/elections/_form.html.erb @@ -1,17 +1,51 @@ -<%= form_with(model: election) do |form| %> +<%= form_with(model: [election, ballot], class: "w-full mx-auto") do |form| %> <% if election.errors.any? %> -
-

<%= pluralize(election.errors.count, "error") %> prohibited this election from being saved:

- -
    - <% election.errors.each do |error| %> -
  • <%= error.full_message %>
  • - <% end %> -
+
+
+
+ + + +
+
+

+ <%= pluralize(election.errors.count, "error") %> prohibited this election from being saved: +

+
    + <% election.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+
<% end %> -
- <%= form.submit %> + + + + + + + + + <% election.nominations.each do |nomination| %> + + + + + <% end %> + +
Nominee
+ <%= nomination.nominee.full_name %> +
+ +
+ <%= form.submit "Submit", class: "inline-flex justify-center py-2 px-4 sm:py-3 sm:px-6 border border-transparent shadow-sm text-sm sm:text-base font-medium rounded-md text-white bg-ruby-red hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ruby-red transition-colors" %>
<% end %> diff --git a/app/views/elections/index.html.erb b/app/views/elections/index.html.erb index 63984e2f..6822de59 100644 --- a/app/views/elections/index.html.erb +++ b/app/views/elections/index.html.erb @@ -1,17 +1,42 @@
-
-

Elections

-
-
+ <% if @current_elections.any? %> +
+
+

Current elections

+
+ + + + + + + + + <% @current_elections.each do |election| %> + + + + + <% end %> + +
Election for
+ <%= link_to election_path(election), class: "text-ruby-red hover:text-red-700 transition-colors" do %> + <%= election.title %> + <% end %> +
+
+ <% end %> -
- <% if @elections.any? %> - <% @elections.each do |election| %> - <%= link_to "Election: #{election.title}", election %> - <% end %> - <% else %> -
-

No elections.

+ <% if @closed_elections.open.any? %> +
+
+

Previous elections

+
+ <% @closed_elections.each do |election| %> + <%= link_to "Current elections: #{election.title}", election %> + <% end %>
<% end %>
diff --git a/app/views/elections/show.html.erb b/app/views/elections/show.html.erb index 5f726224..2bdaad4a 100644 --- a/app/views/elections/show.html.erb +++ b/app/views/elections/show.html.erb @@ -1,7 +1,14 @@ -

<%= notice %>

+
+
+ <%= render @election, ballot: @ballot %> +
-<%= render @election %> - -
- <%= link_to "Back to elections", elections_path %> +
+ <%= link_to elections_path, class: "inline-flex items-center px-4 py-2 text-sm sm:text-base rounded-full bg-gray-100 hover:bg-ruby-red hover:text-white transition-colors border border-gray-200 shadow-sm" do %> + + + + Back to elections + <% end %> +
diff --git a/app/views/elections/show.json.jbuilder b/app/views/elections/show.json.jbuilder deleted file mode 100644 index c13d2b63..00000000 --- a/app/views/elections/show.json.jbuilder +++ /dev/null @@ -1 +0,0 @@ -json.partial! "elections/election", election: @election diff --git a/config/routes.rb b/config/routes.rb index 1448d501..f12fc7fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ Rails.application.routes.draw do - resources :elections, only: [:show, :index] + resources :elections, only: [:show, :index] do + resources :ballots, only: [:create] + end constraints subdomain: "melbourne" do mount(Melbourne::Engine, at: "/") end diff --git a/db/migrate/20251007055656_create_elections.rb b/db/migrate/20251007055656_create_elections.rb index 2edce65d..3d54b396 100644 --- a/db/migrate/20251007055656_create_elections.rb +++ b/db/migrate/20251007055656_create_elections.rb @@ -3,7 +3,8 @@ def change create_table :elections do |t| t.string :title, null: false t.integer :vacancies, default: 1, null: false - t.integer :point_scale, default: 10, null: false + t.integer :maximum_score, default: 5, null: false + t.integer :minimum_score, default: -5, null: false t.datetime :opened_at t.datetime :closed_at