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/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
new file mode 100644
index 00000000..e4b5f1df
--- /dev/null
+++ b/app/controllers/elections_controller.rb
@@ -0,0 +1,73 @@
+class ElectionsController < ApplicationController
+ before_action :set_election, only: %i[ show edit update destroy ]
+
+ # GET /elections or /elections.json
+ def index
+ @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
+ 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.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/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/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
new file mode 100644
index 00000000..f864a2c8
--- /dev/null
+++ b/app/models/election.rb
@@ -0,0 +1,56 @@
+# == Schema Information
+#
+# Table name: elections
+#
+# id :bigint not null, primary key
+# closed_at :datetime
+# opened_at :datetime
+# point_scale :integer default(10), not null
+# title :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, dependent: :restrict_with_error
+ 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) }
+ scope :closed, -> { where('closed_at <= ?', Time.current) }
+
+ validates_presence_of :title, :point_scale, :vacancies
+
+ def open?
+ opened_at.past? && !closed?
+ end
+
+ def closed?
+ closed_at.past?
+ end
+
+ def elected_users
+ if voting_required?
+ top_scoring_candidates
+ else
+ nominated_candidates
+ end
+ end
+
+ private
+
+ def voting_required?
+ nominated_candidates.count > vacancies
+ end
+
+ 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..928c9fc3
--- /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, as: :votable, dependent: :restrict_with_error
+ 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..73c7b6fa 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, 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
new file mode 100644
index 00000000..2b238e7c
--- /dev/null
+++ b/app/models/vote.rb
@@ -0,0 +1,34 @@
+# == Schema Information
+#
+# Table name: votes
+#
+# 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_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 :votable, polymorphic: true
+ belongs_to :voter, class_name: 'User'
+
+ validate :election_must_be_open_validation
+
+ private
+
+ def election_must_be_open_validation
+ errors.add(:base, "Election is not currently open") if votable.is_a?(Nomination) && !votable.election.open?
+ end
+end
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? %>
+
+ <% else %>
+
+
Nominations
+
+
+
+
+
+
+ | Name |
+ Email |
+ Nominated by |
+ Created |
+
+
+
+ <% @nominations.each do |nomination| %>
+
+ | <%= nomination.nominee.full_name %> |
+ <%= nomination.nominee.email %> |
+ <%= nomination.nominated_by.email %> |
+ <%= nomination.created_at.try(:strftime, "%Y-%m-%d") %> |
+
+ <% end %>
+
+
+
+ <%#
%>
+ <%# <% @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? %>
+
+ <% else %>
+
+
+
+
+
+ | ID |
+ Point scale |
+ Title |
+ Vacancies |
+ Created |
+ Opened |
+ Closed |
+
+
+
+ <% @elections.each do |election| %>
+
+ | <%= election.id %> |
+ <%= 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 %> |
+ <%= election.created_at.try(:strftime, "%Y-%m-%d") %> |
+ <%= election.opened_at.try(:strftime, "%Y-%m-%d") %> |
+ <%= election.closed_at.try(:strftime, "%Y-%m-%d") %> |
+
+ <% end %>
+
+
+
+
+ <% if @posts.respond_to?(:total_pages) && @posts.total_pages > 1 %>
+
+
+
+ Showing page <%= @posts.current_page %> of <%= @posts.total_pages %>
+
+
+
+
+ <% 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..d929f25f
--- /dev/null
+++ b/app/views/elections/_election.html.erb
@@ -0,0 +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/_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..5fd50675
--- /dev/null
+++ b/app/views/elections/_form.html.erb
@@ -0,0 +1,51 @@
+<%= 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 %>
+
+
+
+
+ <% end %>
+
+
+
+
+ | Nominee |
+ Score assigned |
+
+
+
+ <% election.nominations.each do |nomination| %>
+
+ |
+ <%= nomination.nominee.full_name %>
+ |
+
+ <%= form.range_field :nomination_id_scores,
+ name: "ballot[nomination_id_scores][#{nomination.id}]",
+ in: 0..election.point_scale,
+ class: "w-full" %>
+ |
+
+ <% end %>
+
+
+
+
+ <%= 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/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..6822de59
--- /dev/null
+++ b/app/views/elections/index.html.erb
@@ -0,0 +1,42 @@
+
+ <% if @current_elections.any? %>
+
+
+
+
+
+ | Election for |
+ Closes in |
+
+
+
+ <% @current_elections.each do |election| %>
+
+ |
+ <%= link_to election_path(election), class: "text-ruby-red hover:text-red-700 transition-colors" do %>
+ <%= election.title %>
+ <% end %>
+ |
+
+ <%= time_ago_in_words(election.closed_at) %>
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+
+ <% if @closed_elections.open.any? %>
+
+
+ <% @closed_elections.each do |election| %>
+ <%= link_to "Current elections: #{election.title}", election %>
+ <% end %>
+
+ <% 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..2bdaad4a
--- /dev/null
+++ b/app/views/elections/show.html.erb
@@ -0,0 +1,14 @@
+
+
+ <%= render @election, ballot: @ballot %>
+
+
+
+ <%= 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/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..f12fc7fc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,4 +1,7 @@
Rails.application.routes.draw do
+ resources :elections, only: [:show, :index] do
+ resources :ballots, only: [:create]
+ end
constraints subdomain: "melbourne" do
mount(Melbourne::Engine, at: "/")
end
@@ -56,6 +59,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
new file mode 100644
index 00000000..3d54b396
--- /dev/null
+++ b/db/migrate/20251007055656_create_elections.rb
@@ -0,0 +1,14 @@
+class CreateElections < ActiveRecord::Migration[8.0]
+ def change
+ create_table :elections do |t|
+ t.string :title, null: false
+ t.integer :vacancies, default: 1, 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
+
+ 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..64050019
--- /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 :votable, null: false, polymorphic: true
+
+ t.timestamps
+ end
+
+ add_index :votes, [:voter_id, :votable_id], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2847ff86..a31383c8 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 "title", 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,137 @@
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.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 ["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
+
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 +410,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 +421,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..66a3ae25
--- /dev/null
+++ b/spec/factories/elections.rb
@@ -0,0 +1,36 @@
+# == Schema Information
+#
+# Table name: elections
+#
+# id :bigint not null, primary key
+# closed_at :datetime
+# opened_at :datetime
+# point_scale :integer default(10), 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
+ title { 'President' }
+ closed_at { nil }
+ 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
new file mode 100644
index 00000000..f1cd6bd8
--- /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, :open
+ 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..61cdb4c7
--- /dev/null
+++ b/spec/factories/votes.rb
@@ -0,0 +1,32 @@
+# == Schema Information
+#
+# Table name: votes
+#
+# 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_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)
+#
+FactoryBot.define do
+ factory :vote do
+ score { 5 }
+ association :voter, factory: :user
+
+ trait :for_nomination do
+ association :votable, factory: :nomination
+ end
+ end
+end
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
new file mode 100644
index 00000000..9156e684
--- /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
+# title :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(:elected_users) { election.elected_users }
+
+ context 'when there are sufficient vacancies for nominees' do
+ let(:election) { create(:election, :open, vacancies: 3) }
+ let(:first_nomination) { create(:nomination, election: election) }
+ let(:second_nomination) { create(:nomination, election: election) }
+
+ it 'returns all the nominated users' do
+ 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_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, :for_nomination, votable: nomination_amir, score: 3)
+ create(:vote, :for_nomination, votable: nomination_amir, score: 1)
+
+ 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, :for_nomination, votable: nomination_carol, score: 4)
+ create(:vote, :for_nomination, votable: nomination_carol, score: 10)
+
+ 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
+ expect(elected_users).to contain_exactly(
+ nomination_carol.nominee,
+ nomination_denise.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..52e4bebd
--- /dev/null
+++ b/spec/models/vote_spec.rb
@@ -0,0 +1,27 @@
+# == Schema Information
+#
+# Table name: votes
+#
+# 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_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)
+#
+require 'rails_helper'
+
+RSpec.describe Vote, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
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