Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -615,6 +617,7 @@ DEPENDENCIES
meta-tags (~> 2.22)
pg
premailer-rails
pry-rails
puma
pygmentize
rails (>= 8.0.0)
Expand Down
52 changes: 52 additions & 0 deletions app/controllers/admin/elections_controller.rb
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions app/controllers/admin/nominations_controller.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions app/controllers/ballots_controller.rb
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions app/controllers/elections_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/helpers/elections_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module ElectionsHelper
end
3 changes: 3 additions & 0 deletions app/models/ballot.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Ballot
include ActiveModel::Model
end
56 changes: 56 additions & 0 deletions app/models/election.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the Election should have a title of what is being voted on.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

position :string was intended for that, maybe the naming could be improved.

has_many :nominations, dependent: :restrict_with_error
has_many :votes, through: :nominations
has_many :nominated_candidates, through: :nominations, source: :nominee
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nominated_candidates somewhat rules out if the vote is on something other than an election (ie, if it was about voting on the approval of a new constitutional change )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was seeing Election as being specifically about committee member election. Since there are different rules around resolutions and special resolutions, it probably makes sense to have separate models. Instead of belonging to nominations, votes would belong to a polymorphic votable — nominations, resolutions, etc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it's not always going to be a resolution either, ideally it could theoretically be voting on a new mascot or something.


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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So would we have multiple elections/votes for each role?

So I can imagine a separate vote for President, and then a vote for Secretary, but let's assume that we have a general members election and Candidate A is keen to run for both Sponsor Outreach and Diversity Lead, my first thought is to the option to vote for
Candidate A - Sponsor Outreach
Candidate A - Diversity National Lead
and then if they were the highest voted option for both then they would choose which role they wanted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention with this method returning multiple users is to accommodate both elections for President etc., and the election of ordinary members.

President etc

  • election vacancies set to 1, and so top_scoring_candidates returns one user.
  • one election per position per year / term of office

Ordinary members

  • AGM decides by resolution number of ordinary members to hold office, sets election vacancies accordingly
  • single election held to fill all vacancies
  • top_scoring_candidates returns number of users required to fill vacancies.

Your scenario is interesting, and possibly I've either misinterpreted the process for ordinary members, or, in "A single election may be held", the word "may" is key. Is it typical to hold an election (ballot) per office to be held by an ordinary member?

In any case, I reckon this scenario of a candidate running for multiple positions could be handled by the candidate withdrawing acceptance for the nomination, and scoping accordingly here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I'm understanding this correctly if we had 4 open ordinary members this would returns the 4 nominations with the most votes? Would that be in order?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, there would be 4 vacancies, so this query would get the 4 top scoring nominations (so not necessarily the nominations with the most votes -- e.g. a nomination with 1 vote with a score of 10 beats a nomination with 9 votes with a score of 1).

It then returns the users referenced by the nominations, in no particular order.

And this code path is only invoked if there are more nominations than vacancies (i.e. voting is required).

User.where(
id: nominations
.joins(:votes)
.group('nominations.id')
.order('SUM(votes.score) DESC')
.limit(vacancies)
.pluck(:nominee_id)
)
end
end
30 changes: 30 additions & 0 deletions app/models/nomination.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
34 changes: 34 additions & 0 deletions app/models/vote.rb
Original file line number Diff line number Diff line change
@@ -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
Loading